refactor(opencode): keep code-mode child invocation local

This commit is contained in:
Aiden Cline 2026-07-03 00:51:28 -05:00
commit 7665d61947
2 changed files with 44 additions and 78 deletions

View file

@ -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* <R>(input: {
plugin: Plugin.Interface
key: string
execute: (args: any, options: ToolExecutionOptions) => R | PromiseLike<R>
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"

View file

@ -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 = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
}),
)
const invokeChildTool = Effect.fn("CodeMode.invokeChildTool")(function* <R>(input: {
plugin: Plugin.Interface
entry: CatalogEntry
args: any
callID: string
options: ToolExecutionOptions
ctx: Tool.Context
execute: (args: any, options: ToolExecutionOptions) => R | PromiseLike<R>
}) {
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)
}),