revert: back out experimental codemode (#35077)

This commit is contained in:
Aiden Cline
2026-07-02 23:57:30 -05:00
committed by GitHub
parent 2ef1a5991c
commit 379adee35c
37 changed files with 47 additions and 12261 deletions
@@ -43,7 +43,6 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
experimentalBackgroundSubagents: enabledByExperimental("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS"),
experimentalLspTy: bool("OPENCODE_EXPERIMENTAL_LSP_TY"),
experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
experimentalCodeMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_CODE_MODE"),
experimentalOxfmt: enabledByExperimental("OPENCODE_EXPERIMENTAL_OXFMT"),
experimentalPlanMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
-19
View File
@@ -159,12 +159,6 @@ export interface Interface {
readonly clients: () => Effect.Effect<Record<string, MCPClient>>
readonly instructions: () => Effect.Effect<ServerInstructions[]>
readonly tools: () => Effect.Effect<Record<string, Tool>>
/**
* Raw MCP tool definitions keyed identically to {@link tools} (`toolName(client, name)`).
* Unlike {@link tools}, these retain the original `inputSchema`/`outputSchema`, which code
* mode uses to render tool signatures (including return types) to the model.
*/
readonly defs: () => Effect.Effect<Record<string, MCPToolDef>>
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
readonly resources: (clientName?: string) => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
readonly resourceTemplates: (
@@ -686,18 +680,6 @@ const layer = Layer.effect(
return result
})
const defs = Effect.fn("MCP.defs")(function* () {
const result: Record<string, MCPToolDef> = {}
const s = yield* InstanceState.get(state)
for (const [clientName, listed] of Object.entries(s.defs)) {
if (s.status[clientName]?.status !== "connected") continue
for (const mcpTool of listed) {
result[McpCatalog.toolName(clientName, mcpTool.name)] = mcpTool
}
}
return result
})
function collectFromConnected<T extends { name: string }>(
s: State,
listFn: (c: Client, timeout?: number) => Promise<T[]>,
@@ -1000,7 +982,6 @@ const layer = Layer.effect(
clients,
instructions,
tools,
defs,
prompts,
resources,
resourceTemplates,
-65
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"
-12
View File
@@ -213,18 +213,6 @@ export function disabled(tools: string[], ruleset: PermissionV1.Ruleset): Set<st
)
}
/**
* The shared tool-visibility predicate: drop every tool a hard deny hides
* ({@link disabled} semantics — a matching `deny` rule with pattern `"*"`).
* Ask-level rules leave a tool fully visible and callable (it prompts at call
* time). Used both when preparing the LLM tool list (request prep) and when
* building/dispatching the code-mode MCP catalog, so the two cannot drift.
*/
export function visibleTools<T>(tools: Record<string, T>, ruleset: PermissionV1.Ruleset): Record<string, T> {
const hidden = disabled(Object.keys(tools), ruleset)
return Object.fromEntries(Object.entries(tools).filter(([name]) => !hidden.has(name)))
}
export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node] })
export * as Permission from "."
+5 -2
View File
@@ -206,8 +206,11 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre
})
function resolveTools(input: Pick<PrepareInput, "tools" | "agent" | "permission" | "user">) {
const visible = Permission.visibleTools(input.tools, Permission.merge(input.agent.permission, input.permission ?? []))
return Record.filter(visible, (_, k) => input.user.tools?.[k] !== false)
const disabled = Permission.disabled(
Object.keys(input.tools),
Permission.merge(input.agent.permission, input.permission ?? []),
)
return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k))
}
export function hasToolCalls(messages: ModelMessage[]): boolean {
-2
View File
@@ -1237,8 +1237,6 @@ const layer = Layer.effect(
Effect.provideService(ToolRegistry.Service, registry),
Effect.provideService(MCP.Service, mcp),
Effect.provideService(Truncate.Service, truncate),
Effect.provideService(Agent.Service, agents),
Effect.provideService(RuntimeFlags.Service, flags),
)
if (lastUser.format?.type === "json_schema") {
+26 -30
View File
@@ -3,7 +3,6 @@ import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Provider } from "@/provider/provider"
import { ProviderTransform } from "@/provider/transform"
import { MCP } from "@/mcp"
import { McpInvoke } from "@/mcp/invoke"
import { Permission } from "@/permission"
import { Tool } from "@/tool/tool"
import { ToolJsonSchema } from "@/tool/json-schema"
@@ -22,7 +21,6 @@ import { EffectBridge } from "@/effect/bridge"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { isRecord } from "@/util/record"
import { RuntimeFlags } from "@/effect/runtime-flags"
const MCP_RESOURCE_TOOLS = {
list: "list_mcp_resources",
@@ -54,7 +52,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
const registry = yield* ToolRegistry.Service
const mcp = yield* MCP.Service
const truncate = yield* Truncate.Service
const flags = yield* RuntimeFlags.Service
const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({
sessionID: input.session.id,
@@ -89,20 +86,11 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
.pipe(Effect.orDie),
})
const mcpTools = yield* mcp.tools()
// When code mode is enabled and MCP tools are present, the registry exposes them
// through the single code-mode `execute` tool (ToolRegistry.tools), so raw per-MCP
// registration is suppressed via the early return below. Code mode is experimental
// and off by default.
const codeMode = flags.experimentalCodeMode && Object.keys(mcpTools).length > 0
const registryTools = yield* registry.tools({
for (const item of yield* registry.tools({
modelID: ModelV2.ID.make(input.model.api.id),
providerID: input.model.providerID,
agent: input.agent,
permission: input.session.permission,
})
for (const item of registryTools) {
})) {
const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
tools[item.id] = tool({
description: item.description,
@@ -393,9 +381,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
})
}
if (codeMode) return tools
for (const [key, item] of Object.entries(mcpTools)) {
for (const [key, item] of Object.entries(yield* mcp.tools())) {
const execute = item.execute
if (!execute) continue
@@ -406,19 +392,29 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
run.promise(
Effect.gen(function* () {
const ctx = context(args, opts)
// Shared MCP middle (before hook → permission ask → Tool.execute span →
// dispatch → after hook); this caller keeps the model-facing shaping edge below.
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* McpInvoke.invoke({
plugin,
key,
execute,
args,
callID: opts.toolCallId,
options: opts,
sessionID: ctx.sessionID,
messageID: input.processor.message.id,
ask: ctx.ask,
})
yield* plugin.trigger(
"tool.execute.before",
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },
{ args },
)
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.gen(function* () {
yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
return yield* Effect.promise(() => execute(args, opts))
}).pipe(
Effect.withSpan("Tool.execute", {
attributes: {
"tool.name": key,
"tool.call_id": opts.toolCallId,
"session.id": ctx.sessionID,
"message.id": input.processor.message.id,
},
}),
)
yield* plugin.trigger(
"tool.execute.after",
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
result,
)
const textParts: string[] = []
const attachments: Omit<SessionV1.FilePart, "id" | "sessionID" | "messageID">[] = []
-432
View File
@@ -1,432 +0,0 @@
import * as Tool from "./tool"
import type { Tool as AITool } from "ai"
import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js"
import { Cause, Effect, Schema } from "effect"
import {
CodeMode,
Tool as SandboxTool,
toolError,
type ExecuteResult,
type JsonSchema,
type ToolDefinition,
} 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"
import { Plugin } from "@/plugin"
export const CODE_MODE_TOOL = "execute"
// OpenCode sets NO execution limits: no timeout, no tool-call cap, and no CodeMode output
// truncation. Cancelling the tool call aborts `ctx.abort`, which wins the race below and
// interrupts the execution fiber — structured concurrency takes the program and its
// in-flight child calls down with it; every child call is permission-gated anyway. Output
// bounding is OpenCode's native tool-output truncation (Tool.define's shared wrapper),
// which applies to `execute` like any other tool and dumps the full output to a file when
// it triggers.
// The static base description. The full usage guide and the grouped, permission-filtered
// tool catalog are appended per agent by the registry (`describeCodeMode`, the same
// composition point `describeTask` uses), so `plugin.trigger("tool.definition")` sees this
// base first, exactly like the task tool.
const DESCRIPTION = [
"Execute a JavaScript/TypeScript program that orchestrates the connected MCP tools inside a confined runtime.",
"The full usage guide and the catalog of available tools follow below.",
].join("\n")
export const Parameters = Schema.Struct({
code: Schema.String.annotate({
description: [
"JavaScript source to execute.",
"Inside CodeMode, `tools` contains only the MCP/CodeMode tools listed in this execute tool's description; top-level opencode tools like bash, read, or lsp are not available unless listed there.",
"Call available tools using the exact signatures shown in this execute tool's description, compose the results, and `return` the final value.",
].join(" "),
}),
})
/** One child tool call, surfaced live so the UI can render a per-call line that
* updates as the program runs. `tool` is the dotted path (e.g. `github.create_issue`). */
export type CallEntry = { tool: string; status: "running" | "completed" | "error"; input?: Record<string, unknown> }
type Metadata = {
toolCalls: CallEntry[]
error?: boolean
}
/**
* A tool-result attachment: identical to a session `FilePart` (minus the ids) and
* carrying the actual bytes (`url`, often a base64 `data:` URL), so it lowers 1:1 into
* `Tool.ExecuteResult.attachments`. Attachments never enter the sandbox media stripped
* from child tool results is accumulated host-side and returned on the outer `execute`
* result, where the existing attachment plumbing turns it into visible images/files.
*/
export type Attachment = NonNullable<Tool.ExecuteResult["attachments"]>[number]
/** One MCP tool in the grouped catalog: the flat `server_tool` key split into its
* namespace (`server`) and local name, with the raw JSON Schemas used for rendering. */
export type CatalogEntry = {
path: string
key: string
server: string
local: string
description: string
tool: AITool
inputSchema: JsonSchema
outputSchema?: JsonSchema
}
/** Render-only cast: MCP definitions carry JSON Schema documents already. */
const toJsonSchema = (schema: unknown): JsonSchema => schema as JsonSchema
/** The input schema for entries without a cached MCP definition, recovered from the
* ai-sdk tool when possible so signatures stay informative. */
function fallbackInputSchema(tool: AITool): JsonSchema {
const schema = (tool.inputSchema as { jsonSchema?: unknown } | undefined)?.jsonSchema
if (schema && typeof schema === "object") return toJsonSchema(schema)
return { type: "object", properties: {} }
}
/**
* Group the flat `server_tool` catalog into per-server namespaces. `servers` are
* the sanitized MCP client names; the longest matching prefix wins so a server
* named `a_b` beats `a` for the key `a_b_tool`. `mcpDefs` carries the raw MCP
* definitions (keyed identically) so each entry retains its original
* `inputSchema`/`outputSchema` for signature rendering.
*/
export function groupByServer(
mcpTools: Record<string, AITool>,
servers: readonly string[],
mcpDefs: Record<string, MCPToolDef> = {},
): Map<string, CatalogEntry[]> {
const byLongest = [...servers].sort((a, b) => b.length - a.length)
const groups = new Map<string, CatalogEntry[]>()
for (const key of Object.keys(mcpTools).sort((a, b) => a.localeCompare(b))) {
const server =
byLongest.find((name) => key.startsWith(name + "_")) ?? (key.includes("_") ? key.slice(0, key.indexOf("_")) : key)
const local = server && key.startsWith(server + "_") ? key.slice(server.length + 1) : key
const def = mcpDefs[key]
const entry: CatalogEntry = {
path: `${server}.${local}`,
key,
server,
local,
description: mcpTools[key]!.description ?? def?.description ?? "",
tool: mcpTools[key]!,
inputSchema: def?.inputSchema ? toJsonSchema(def.inputSchema) : fallbackInputSchema(mcpTools[key]!),
...(def?.outputSchema ? { outputSchema: toJsonSchema(def.outputSchema) } : {}),
}
groups.set(server, [...(groups.get(server) ?? []), entry])
}
return groups
}
/** The executable catalog for a (already permission-filtered) MCP tool set: grouped
* entries, minus any without an ai-sdk execute function. */
export function buildCatalog(
mcpTools: Record<string, AITool>,
mcpDefs: Record<string, MCPToolDef>,
servers: readonly string[],
): CatalogEntry[] {
return [...groupByServer(mcpTools, servers, mcpDefs).values()]
.flat()
.filter((entry) => entry.tool.execute !== undefined)
}
/**
* The model-facing usage guide plus grouped catalog for the given MCP tool set: the
* CodeMode instructions for this tool tree (syntax guide + tool signatures, or the
* namespace overview + search for large catalogs). Callers pass an already
* permission-filtered tool set hard-denied tools never enter the catalog. The preview
* tree's runs are placeholders rendering never invokes them.
*/
export function catalogInstructions(
mcpTools: Record<string, AITool>,
mcpDefs: Record<string, MCPToolDef>,
servers: readonly string[],
): string {
const catalog = buildCatalog(mcpTools, mcpDefs, servers)
return CodeMode.make({
tools: toolTree(catalog, () => () => Effect.fail(toolError("Tool preview is not executable."))),
}).instructions()
}
function displayInput(input: unknown): Record<string, unknown> | undefined {
if (input === null || input === undefined) return
if (typeof input === "object" && !Array.isArray(input)) {
const value = input as Record<string, unknown>
if (Object.keys(value).length > 0) return value
return
}
return { input }
}
const lastSegment = (uri: string) => {
const trimmed = uri.split(/[?#]/, 1)[0]!.replace(/\/+$/, "")
const segment = trimmed.slice(trimmed.lastIndexOf("/") + 1)
return segment.length > 0 ? segment : undefined
}
const dataUrl = (mime: string, base64: string) => `data:${mime};base64,${base64}`
/** The stand-in payload for a media-only tool result, so the program knows the call
* succeeded even though the media itself never enters the sandbox. */
const mediaMarker = (files: number, images: number) => {
const noun = files === images ? "image" : "file"
return `[${files} ${noun}${files === 1 ? "" : "s"} attached to the result]`
}
/**
* Reduce a raw MCP tool result to the value the sandbox sees. Structured content is
* preferred; otherwise text blocks are joined. Media blocks (image/audio/resource
* blob/resource_link) NEVER enter the sandbox: they are stripped into `collect`, the
* per-execution attachment accumulator, and a tool that returned ONLY media yields a
* small text marker instead. Lenient never throws on unexpected shapes.
*/
export function toSandboxResult(raw: unknown, collect: (attachment: Attachment) => void): unknown {
if (raw === null || typeof raw !== "object") return raw
const record = raw as { structuredContent?: unknown; content?: unknown }
const content = Array.isArray(record.content) ? record.content : []
const text: string[] = []
let files = 0
let images = 0
const push = (attachment: Attachment) => {
files += 1
if (attachment.mime.startsWith("image/")) images += 1
collect(attachment)
}
for (const item of content) {
if (!item || typeof item !== "object") continue
const block = item as Record<string, unknown>
switch (block.type) {
case "text":
if (typeof block.text === "string") text.push(block.text)
break
case "image":
case "audio":
if (typeof block.data === "string" && typeof block.mimeType === "string") {
push({ type: "file", mime: block.mimeType, url: dataUrl(block.mimeType, block.data) })
}
break
case "resource": {
const res = block.resource as Record<string, unknown> | undefined
if (res && typeof res === "object") {
const mime = typeof res.mimeType === "string" ? res.mimeType : "application/octet-stream"
const uri = typeof res.uri === "string" ? res.uri : undefined
if (typeof res.blob === "string") {
push({ type: "file", mime, url: dataUrl(mime, res.blob), filename: uri ? lastSegment(uri) : undefined })
} else if (typeof res.text === "string") {
text.push(res.text)
}
}
break
}
case "resource_link":
if (typeof block.uri === "string") {
push({
type: "file",
mime: typeof block.mimeType === "string" ? block.mimeType : "application/octet-stream",
url: block.uri,
filename: typeof block.name === "string" ? block.name : lastSegment(block.uri),
})
}
break
}
}
if (record.structuredContent !== undefined && record.structuredContent !== null) return record.structuredContent
if (text.length > 0) return text.join("\n")
if (files > 0) return mediaMarker(files, images)
if (Array.isArray(record.content)) return null // MCP-shaped result with nothing extractable
return raw
}
/**
* Append captured `console.*` output to the model-facing text as a trailing `Logs:` section,
* so a program's diagnostics ride back alongside its result on success AND on error.
* Returns the text unchanged when nothing was logged. This is the sandbox's only
* stdout-like channel it goes to the model, not the user.
*/
export function withLogs(output: string, logs: ReadonlyArray<string> = []): string {
if (logs.length === 0) return output
const section = "Logs:\n" + logs.join("\n")
return output.length > 0 ? `${output}\n\n${section}` : section
}
/** Coerce the program's return value to model-facing text without ever failing on shape. */
export function formatValue(value: unknown): string {
if (typeof value === "string") return value
if (value === undefined) return "undefined"
try {
return JSON.stringify(value, null, 2) ?? String(value)
} catch {
return String(value)
}
}
type Run = (input: unknown) => Effect.Effect<unknown, unknown>
/** Build the `tools.<server>.<tool>` tree CodeMode executes against, one
* `Tool.make` definition per MCP tool with its render-only JSON Schemas. */
function toolTree(catalog: readonly CatalogEntry[], run: (entry: CatalogEntry) => Run) {
const tree: Record<string, Record<string, ToolDefinition>> = {}
for (const entry of catalog) {
const namespace = (tree[entry.server] ??= {})
namespace[entry.local] = SandboxTool.make({
description: entry.description,
input: entry.inputSchema,
output: entry.outputSchema,
run: run(entry),
})
}
return tree
}
/** Failures inside a child call plugin hook failures, permission denials, and tool
* failures alike become safe, catchable in-program errors via toolError, so a
* program can try/catch one call without the whole execution dying. Interruption
* (user cancel) keeps propagating as interruption. */
const toCatchable = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
const error = Cause.squash(cause)
return Effect.fail(toolError(error instanceof Error ? error.message : String(error), error))
}),
)
export const CodeModeTool = Tool.define(
CODE_MODE_TOOL,
Effect.gen(function* () {
const mcp = yield* MCP.Service
const agents = yield* Agent.Service
const sessions = yield* Session.Service
const plugin = yield* Plugin.Service
const init: Tool.DefWithoutID<typeof Parameters, Metadata> = {
description: DESCRIPTION,
parameters: Parameters,
execute: Effect.fn("CodeMode.execute")(function* (params, ctx) {
// Already cancelled: don't start the program at all. (The mid-flight case is the
// race below; racing alone would still let the program run its first steps.)
if (ctx.abort.aborted) {
return {
title: CODE_MODE_TOOL,
metadata: { toolCalls: [], error: true },
output: "Execution cancelled.",
} satisfies Tool.ExecuteResult<Metadata>
}
// A fresh MCP snapshot per execution, so the runtime tracks live tool-list
// changes, filtered with the same merged agent+session ruleset that gates
// `ctx.ask` (see SessionTools.context). A hard-denied tool never enters the
// tree, so it is not dispatchable even if the model guesses its name — the
// program gets the normal unknown-tool diagnostic, not a permission error.
const agent = yield* agents.get(ctx.agent)
const session = yield* sessions.get(ctx.sessionID).pipe(Effect.orDie)
const ruleset = Permission.merge(agent.permission, session.permission ?? [])
const mcpTools = Permission.visibleTools(yield* mcp.tools(), ruleset)
const servers = Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize)
const catalog = buildCatalog(mcpTools, yield* mcp.defs(), servers)
const calls: CallEntry[] = []
// Media stripped from child tool results accumulates here for the life of the
// call; the bytes never enter the sandbox (see toSandboxResult).
const attachments: Attachment[] = []
const collect = (attachment: Attachment) => void attachments.push(attachment)
// Stream the current call list to the UI. Sent on every status change so the
// 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
// 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
// in-program error (toCatchable); the raw result is then shaped for the sandbox.
let childCalls = 0
const callTool = (entry: CatalogEntry) => (input: unknown) =>
toCatchable(
Effect.gen(function* () {
childCalls += 1
const raw = yield* McpInvoke.invoke({
plugin,
key: entry.key,
execute: entry.tool.execute!,
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,
})
return toSandboxResult(raw, collect)
}),
)
const runtime = CodeMode.make({
tools: toolTree(catalog, callTool),
onToolCallStart: ({ index, name, input }) =>
Effect.suspend(() => {
const shown = displayInput(input)
calls[index] = { tool: name, status: "running", ...(shown ? { input: shown } : {}) }
return publish()
}),
onToolCallEnd: ({ index, outcome }) =>
Effect.suspend(() => {
const current = calls[index]
if (current) calls[index] = { ...current, status: outcome === "success" ? "completed" : "error" }
return publish()
}),
})
// The shared tool runner does not wire ctx.abort to fiber interruption (it runs
// tools via Effect.runPromise with no abort handling), so without this race the
// program would keep running after the user cancels. The abort signal winning the
// race interrupts the execution fiber; the cancelled result keeps the runner's
// post-abort bookkeeping (completeToolCall) on its normal path.
const cancelled = Effect.callback<ExecuteResult>((resume) => {
const onAbort = () =>
resume(
Effect.succeed<ExecuteResult>({
ok: false,
error: { kind: "ExecutionFailure", message: "Execution cancelled." },
toolCalls: calls.map((call) => ({ name: call.tool })),
}),
)
if (ctx.abort.aborted) return onAbort()
ctx.abort.addEventListener("abort", onAbort, { once: true })
return Effect.sync(() => ctx.abort.removeEventListener("abort", onAbort))
})
const result = yield* Effect.raceFirst(runtime.execute(params.code), cancelled)
const logs = result.logs ?? []
const attached = attachments.length > 0 ? { attachments } : {}
if (result.ok) {
return {
title: CODE_MODE_TOOL,
metadata: { toolCalls: calls },
output: withLogs(formatValue(result.value), logs),
...attached,
} satisfies Tool.ExecuteResult<Metadata>
}
// Diagnostics may carry suggestions (e.g. pointing an unknown tool at
// discovery); append the ones the message doesn't already contain.
const hints = (result.error.suggestions ?? []).filter((hint) => !result.error.message.includes(hint))
return {
title: CODE_MODE_TOOL,
metadata: { toolCalls: calls, error: true },
output: withLogs([result.error.message, ...hints].join("\n"), logs),
...attached,
} satisfies Tool.ExecuteResult<Metadata>
}),
}
return init
}),
)
+1 -32
View File
@@ -26,9 +26,6 @@ import { Plugin } from "../plugin"
import { Provider } from "@/provider/provider"
import { WebSearchTool } from "./websearch"
import { CodeModeTool, catalogInstructions } from "./code-mode"
import { MCP } from "@/mcp"
import { McpCatalog } from "@/mcp/catalog"
import { LspTool } from "./lsp"
import * as Truncate from "./truncate"
import { ApplyPatchTool } from "./apply_patch"
@@ -54,7 +51,6 @@ import { BackgroundJob } from "@/background/job"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
export function webSearchEnabled(providerID: ProviderV2.ID, flags = { exa: false, parallel: false }) {
return providerID === ProviderV2.ID.opencode || flags.exa || flags.parallel
@@ -78,7 +74,6 @@ export interface Interface {
providerID: ProviderV2.ID
modelID: ModelV2.ID
agent: Agent.Info
permission?: PermissionV1.Ruleset
}) => Effect.Effect<Tool.Def[]>
}
@@ -92,7 +87,6 @@ const layer = Layer.effect(
const agents = yield* Agent.Service
const truncate = yield* Truncate.Service
const flags = yield* RuntimeFlags.Service
const mcp = yield* MCP.Service
const invalid = yield* InvalidTool
const task = yield* TaskTool
@@ -110,7 +104,6 @@ const layer = Layer.effect(
const greptool = yield* GrepTool
const patchtool = yield* ApplyPatchTool
const skilltool = yield* SkillTool
const codemode = yield* CodeModeTool
const agent = yield* Agent.Service
const state = yield* InstanceState.make<State>(
@@ -218,7 +211,6 @@ const layer = Layer.effect(
question: Tool.init(question),
lsp: Tool.init(lsptool),
plan: Tool.init(plan),
codemode: Tool.init(codemode),
})
return {
@@ -240,7 +232,6 @@ const layer = Layer.effect(
tool.patch,
...(flags.experimentalLspTool ? [tool.lsp] : []),
...(flags.experimentalPlanMode && flags.client === "cli" ? [tool.plan] : []),
...(flags.experimentalCodeMode ? [tool.codemode] : []),
],
task: tool.task,
read: tool.read,
@@ -272,28 +263,11 @@ const layer = Layer.effect(
return ["Available agent types and the tools they have access to:", description].join("\n")
})
// The grouped MCP-tool catalog appended to the code-mode base description, built
// fresh per turn so it tracks live tool-list changes. Hard-denied tools (the shared
// Permission.visibleTools predicate over the agent's ruleset) never enter the
// catalog, its inlined signatures, or the in-program search index.
const describeCodeMode = Effect.fn("ToolRegistry.describeCodeMode")(function* (
agent: Agent.Info,
permission?: PermissionV1.Ruleset,
) {
const visible = Permission.visibleTools(yield* mcp.tools(), Permission.merge(agent.permission, permission ?? []))
const servers = Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize)
return catalogInstructions(visible, yield* mcp.defs(), servers)
})
const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
// Consulted once before the synchronous filter: code mode registers only when the
// experimental flag is on AND at least one MCP tool is connected.
const mcpToolCount = flags.experimentalCodeMode ? Object.keys(yield* mcp.tools()).length : 0
const filtered = (yield* all()).filter((tool) => {
if (tool.id === WebSearchTool.id) {
return webSearchEnabled(input.providerID, { exa: flags.enableExa, parallel: flags.enableParallel })
}
if (tool.id === CodeModeTool.id) return flags.experimentalCodeMode && mcpToolCount > 0
const usePatch =
input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4")
@@ -318,11 +292,7 @@ const layer = Layer.effect(
: undefined
return {
id: tool.id,
description: [
output.description,
tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined,
tool.id === CodeModeTool.id ? yield* describeCodeMode(input.agent, input.permission) : undefined,
]
description: [output.description, tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined]
.filter(Boolean)
.join("\n"),
parameters: output.parameters,
@@ -436,7 +406,6 @@ export const node = LayerNode.make({
LSP.node,
Instruction.node,
FSUtil.node,
MCP.node,
EventV2Bridge.node,
httpClient,
CrossSpawnSpawner.node,