diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index 2481f104ed1..415a0643e4f 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -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 } }, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 8554b44a727..33dba397f93 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -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( + toolID: string, + args: any, + context: { sessionID: string; callID: string }, + executeFn: () => Promise, + ): Promise { + 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< @@ -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 @@ -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 = { @@ -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() @@ -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)) }, }) } @@ -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[] = [] @@ -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"), diff --git a/packages/opencode/src/session/tool-executor.ts b/packages/opencode/src/session/tool-executor.ts new file mode 100644 index 00000000000..b58e5ed3473 --- /dev/null +++ b/packages/opencode/src/session/tool-executor.ts @@ -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( + toolID: string, + tool: Awaited>, + 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 + } + } +}