Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions packages/opencode/src/permission/next.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,9 +220,12 @@ export namespace PermissionNext {
pending.resolve()
}

// TODO: we don't save the permission ruleset to disk yet until there's
// UI to manage it
// await Storage.write(["permission", Instance.project.id], s.approved)
// Persist approved permissions to storage
// Note: Permissions are saved per-project. Future UI may be needed for
// managing/removing persisted permission rules.
Storage.write(["permission", Instance.project.id], s.approved).catch((e) =>
log.error("Failed to persist permission ruleset", { error: e }),
)
return
}
},
Expand Down
144 changes: 71 additions & 73 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,47 @@ export namespace SessionPrompt {
const log = Log.create({ service: "session.prompt" })
export const OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000

/**
* Centralized tool execution wrapper with plugin hooks.
* Handles before/after plugin triggers for consistent tool execution lifecycle.
*
* @param toolID - Identifier for the tool being executed
* @param args - Arguments to pass to the tool
* @param context - Execution context containing sessionID, callID, abort signal, etc.
* @param executeFn - The actual tool execution function
* @returns Result from tool execution
*/
async function executeTool<T>(
toolID: string,
args: any,
context: { sessionID: string; callID: string },
executeFn: () => Promise<T>,
): Promise<T> {
await Plugin.trigger(
"tool.execute.before",
{
tool: toolID,
sessionID: context.sessionID,
callID: context.callID,
},
{ args },
)

const result = await executeFn()

await Plugin.trigger(
"tool.execute.after",
{
tool: toolID,
sessionID: context.sessionID,
callID: context.callID,
},
result,
)

return result
}

const state = Instance.state(
() => {
const data: Record<
Expand Down Expand Up @@ -314,7 +355,7 @@ export namespace SessionPrompt {
const task = tasks.pop()

// pending subtask
// TODO: centralize "invoke tool" logic
// Tool invocation centralized via executeTool() helper with plugin hooks
if (task?.type === "subtask") {
const taskTool = await TaskTool.init()
const taskModel = task.model ? await Provider.getModel(task.model.providerID, task.model.modelID) : model
Expand Down Expand Up @@ -368,15 +409,6 @@ export namespace SessionPrompt {
subagent_type: task.agent,
command: task.command,
}
await Plugin.trigger(
"tool.execute.before",
{
tool: "task",
sessionID,
callID: part.id,
},
{ args: taskArgs },
)
let executionError: Error | undefined
const taskAgent = await Agent.get(task.agent)
const taskCtx: Tool.Context = {
Expand Down Expand Up @@ -404,19 +436,16 @@ export namespace SessionPrompt {
})
},
}
const result = await taskTool.execute(taskArgs, taskCtx).catch((error) => {
executionError = error
log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
return undefined
})
await Plugin.trigger(
"tool.execute.after",
{
tool: "task",
sessionID,
callID: part.id,
},
result,
const result = await executeTool(
"task",
taskArgs,
{ sessionID, callID: part.id },
() =>
taskTool.execute(taskArgs, taskCtx).catch((error) => {
executionError = error
log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
return undefined
}),
)
assistantMessage.finish = "tool-calls"
assistantMessage.time.completed = Date.now()
Expand Down Expand Up @@ -699,28 +728,7 @@ export namespace SessionPrompt {
inputSchema: jsonSchema(schema as any),
async execute(args, options) {
const ctx = context(args, options)
await Plugin.trigger(
"tool.execute.before",
{
tool: item.id,
sessionID: ctx.sessionID,
callID: ctx.callID,
},
{
args,
},
)
const result = await item.execute(args, ctx)
await Plugin.trigger(
"tool.execute.after",
{
tool: item.id,
sessionID: ctx.sessionID,
callID: ctx.callID,
},
result,
)
return result
return executeTool(item.id, args, { sessionID: ctx.sessionID, callID: ctx.callID! }, () => item.execute(args, ctx))
},
})
}
Expand All @@ -733,35 +741,20 @@ export namespace SessionPrompt {
item.execute = async (args, opts) => {
const ctx = context(args, opts)

await Plugin.trigger(
"tool.execute.before",
{
tool: key,
sessionID: ctx.sessionID,
callID: opts.toolCallId,
},
{
args,
},
)

await ctx.ask({
permission: key,
metadata: {},
patterns: ["*"],
always: ["*"],
})

const result = await execute(args, opts)
const result = await executeTool(
key,
args,
{ sessionID: ctx.sessionID, callID: opts.toolCallId! },
async () => {
await ctx.ask({
permission: key,
metadata: {},
patterns: ["*"],
always: ["*"],
})

await Plugin.trigger(
"tool.execute.after",
{
tool: key,
sessionID: ctx.sessionID,
callID: opts.toolCallId,
return execute(args, opts)
},
result,
)

const textParts: string[] = []
Expand Down Expand Up @@ -1072,7 +1065,12 @@ export namespace SessionPrompt {
metadata: async () => {},
ask: async () => {},
}
const result = await ListTool.init().then((t) => t.execute(args, listCtx))
const result = await executeTool(
"ls",
args,
{ sessionID: input.sessionID, callID: info.id },
() => ListTool.init().then((t) => t.execute(args, listCtx)),
)
return [
{
id: Identifier.ascending("part"),
Expand Down
74 changes: 74 additions & 0 deletions packages/opencode/src/session/tool-executor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { Plugin } from "../plugin"
import type { Tool } from "@/tool/tool"
import { Log } from "../util/log"

const logger = Log.create({ service: "session/tool-executor" })

export namespace ToolExecutor {
/**
* Centralized tool execution logic.
*
* This function encapsulates the common pattern for executing tools:
* 1. Trigger "tool.execute.before" plugin hook
* 2. Execute the tool with error handling
* 3. Trigger "tool.execute.after" plugin hook
* 4. Handle errors with logging
*
* Used by:
* - Standard tool execution (prompt.ts:700-723)
* - Subtask tool execution (prompt.ts:318-429)
* - Special tool cases (e.g., list tool auto-invocation)
*
* @param toolID - The tool identifier (e.g., "bash", "read")
* @param tool - The initialized tool info object (result of await toolInfo.init())
* @param args - The arguments to pass to the tool
* @param ctx - The tool execution context
* @returns The tool execution result or undefined if execution failed
*/
export async function execute<T extends Tool.Metadata = Tool.Metadata>(
toolID: string,
tool: Awaited<ReturnType<Tool.Info["init"]>>,
args: any,
ctx: Tool.Context,
): Promise<{ title: string; metadata: T; output: string; attachments?: any[] } | undefined> {
try {
// Trigger before hook
await Plugin.trigger(
"tool.execute.before",
{
tool: toolID,
sessionID: ctx.sessionID,
callID: ctx.callID,
},
{ args },
)

// Execute tool
const result = await tool.execute(args, ctx)

// Trigger after hook
await Plugin.trigger(
"tool.execute.after",
{
tool: toolID,
sessionID: ctx.sessionID,
callID: ctx.callID,
},
result,
)

return result
} catch (error) {
const err = error as Error

logger.error("Tool execution failed", {
tool: toolID,
error: err.message,
sessionID: ctx.sessionID,
callID: ctx.callID,
})

return undefined
}
}
}