From 7665d619472051d2ef88df376b5c33ab39281959 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 3 Jul 2026 00:51:28 -0500 Subject: [PATCH] refactor(opencode): keep code-mode child invocation local --- packages/opencode/src/mcp/invoke.ts | 65 ------------------------- packages/opencode/src/tool/code-mode.ts | 57 +++++++++++++++++----- 2 files changed, 44 insertions(+), 78 deletions(-) delete mode 100644 packages/opencode/src/mcp/invoke.ts diff --git a/packages/opencode/src/mcp/invoke.ts b/packages/opencode/src/mcp/invoke.ts deleted file mode 100644 index 52bc5e4f73..0000000000 --- a/packages/opencode/src/mcp/invoke.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { ToolExecutionOptions } from "ai" -import { Effect } from "effect" -import type { Plugin } from "@/plugin" -import type { Tool } from "@/tool/tool" - -/** - * The shared middle of every raw MCP tool invocation: plugin `tool.execute.before` - * hook → permission ask → dispatch through the ai-sdk tool's execute inside the - * `Tool.execute` tracing span → plugin `tool.execute.after` hook. Used by both the - * legacy per-tool registration in `SessionTools.resolve` and code-mode child calls, - * so MCP tools execute identically on either path. - * - * Returns the RAW result the ai-sdk execute resolved with — callers own their - * shaping edge (model-facing text/attachment shaping + truncation on the legacy - * path, `toSandboxResult` for code-mode child calls). The after hook fires here - * with that same raw result, which is exactly what the legacy loop always passed - * (the raw MCP result, not the shaped `{title, output, metadata}`), so the hook - * payload cannot drift between callers. - * - * `callID` is the hook/span identity — an opaque string nothing parses. Legacy - * passes the ai-sdk `toolCallId`; code-mode child calls pass a synthetic - * `${parentCallID}/${n}`. `options.toolCallId` is what the ai-sdk execute sees and - * stays each caller's existing value. Failure semantics belong to the caller: hook - * failures, permission denials, and tool failures all propagate — the legacy path - * lets them fail the tool call as before; code mode converts them into catchable - * in-program tool errors at its edge. - */ -export const invoke = Effect.fn("McpInvoke.invoke")(function* (input: { - plugin: Plugin.Interface - key: string - execute: (args: any, options: ToolExecutionOptions) => R | PromiseLike - args: any - callID: string - options: ToolExecutionOptions - sessionID: string - messageID: string - ask: Tool.Context["ask"] -}) { - yield* input.plugin.trigger( - "tool.execute.before", - { tool: input.key, sessionID: input.sessionID, callID: input.callID }, - { args: input.args }, - ) - const result: R = yield* Effect.gen(function* () { - yield* input.ask({ permission: input.key, metadata: {}, patterns: ["*"], always: ["*"] }) - return yield* Effect.promise(() => Promise.resolve(input.execute(input.args, input.options))) - }).pipe( - Effect.withSpan("Tool.execute", { - attributes: { - "tool.name": input.key, - "tool.call_id": input.callID, - "session.id": input.sessionID, - "message.id": input.messageID, - }, - }), - ) - yield* input.plugin.trigger( - "tool.execute.after", - { tool: input.key, sessionID: input.sessionID, callID: input.callID, args: input.args }, - result, - ) - return result -}) - -export * as McpInvoke from "./invoke" diff --git a/packages/opencode/src/tool/code-mode.ts b/packages/opencode/src/tool/code-mode.ts index bef135b03a..6c78d627eb 100644 --- a/packages/opencode/src/tool/code-mode.ts +++ b/packages/opencode/src/tool/code-mode.ts @@ -1,5 +1,5 @@ import * as Tool from "./tool" -import type { Tool as AITool } from "ai" +import type { Tool as AITool, ToolExecutionOptions } from "ai" import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js" import { Cause, Effect, Schema } from "effect" import { @@ -12,7 +12,6 @@ import { } from "@opencode-ai/codemode" import { MCP } from "@/mcp" import { McpCatalog } from "@/mcp/catalog" -import { McpInvoke } from "@/mcp/invoke" import { Agent } from "@/agent/agent" import { Session } from "@/session/session" import { Permission } from "@/permission" @@ -294,6 +293,41 @@ const toCatchable = (effect: Effect.Effect) => }), ) +const invokeChildTool = Effect.fn("CodeMode.invokeChildTool")(function* (input: { + plugin: Plugin.Interface + entry: CatalogEntry + args: any + callID: string + options: ToolExecutionOptions + ctx: Tool.Context + execute: (args: any, options: ToolExecutionOptions) => R | PromiseLike +}) { + yield* input.plugin.trigger( + "tool.execute.before", + { tool: input.entry.key, sessionID: input.ctx.sessionID, callID: input.callID }, + { args: input.args }, + ) + const result: R = yield* Effect.gen(function* () { + yield* input.ctx.ask({ permission: input.entry.key, metadata: {}, patterns: ["*"], always: ["*"] }) + return yield* Effect.promise(() => Promise.resolve(input.execute(input.args, input.options))) + }).pipe( + Effect.withSpan("Tool.execute", { + attributes: { + "tool.name": input.entry.key, + "tool.call_id": input.callID, + "session.id": input.ctx.sessionID, + "message.id": input.ctx.messageID, + }, + }), + ) + yield* input.plugin.trigger( + "tool.execute.after", + { tool: input.entry.key, sessionID: input.ctx.sessionID, callID: input.callID, args: input.args }, + result, + ) + return result +}) + export const CodeModeTool = Tool.define( CODE_MODE_TOOL, Effect.gen(function* () { @@ -336,11 +370,10 @@ export const CodeModeTool = Tool.define( // tool part shows each child call appearing and resolving while the program runs. const publish = () => ctx.metadata({ title: CODE_MODE_TOOL, metadata: { toolCalls: calls.map((c) => ({ ...c })) } }) - // One CodeMode tool per MCP tool, running the same shared middle as legacy - // per-tool registration (McpInvoke.invoke: plugin before hook → permission - // ask → Tool.execute span → dispatch through the ai-sdk wrapper, which owns - // callTool timeouts/progress and turns an MCP isError into a thrown Error → - // plugin after hook), so plugins observe child calls too. Each child gets a + // One CodeMode tool per MCP tool: plugin before hook → permission ask → + // Tool.execute span → dispatch through the ai-sdk wrapper, which owns callTool + // timeouts/progress and turns an MCP isError into a thrown Error → plugin after + // hook. This keeps plugins aware of child calls. Each child gets a // synthetic hook/span callID `${parentCallID}/${n}` (per-execution counter, // opaque — nothing parses it); the ai-sdk toolCallId is unchanged. Failures — // hook, denial, or tool — fail only that child call as a safe, catchable @@ -350,16 +383,14 @@ export const CodeModeTool = Tool.define( toCatchable( Effect.gen(function* () { childCalls += 1 - const raw = yield* McpInvoke.invoke({ + const raw = yield* invokeChildTool({ plugin, - key: entry.key, - execute: entry.tool.execute!, + entry, args: input ?? {}, callID: `${ctx.callID ?? entry.key}/${childCalls}`, options: { toolCallId: ctx.callID ?? entry.key, abortSignal: ctx.abort, messages: [] }, - sessionID: ctx.sessionID, - messageID: ctx.messageID, - ask: ctx.ask, + ctx, + execute: entry.tool.execute!, }) return toSandboxResult(raw, collect) }),