From caf2d59d7f869e8c5be8f27db36322f66f762538 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 3 Jul 2026 00:29:54 -0500 Subject: [PATCH] feat(opencode): expose code-mode execute behind experimental flag --- packages/opencode/src/effect/runtime-flags.ts | 1 + packages/opencode/src/session/llm/request.ts | 6 +- packages/opencode/src/session/prompt.ts | 2 + packages/opencode/src/session/tools.ts | 56 +++--- packages/opencode/src/tool/registry.ts | 31 +++- packages/opencode/test/session/tools.test.ts | 164 ++++++++++++++++++ packages/opencode/test/tool/registry.test.ts | 120 +++++++++++++ 7 files changed, 350 insertions(+), 30 deletions(-) create mode 100644 packages/opencode/test/session/tools.test.ts diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index 4baf867db6..2dd050ad90 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -43,6 +43,7 @@ export class Service extends ConfigService.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"), diff --git a/packages/opencode/src/session/llm/request.ts b/packages/opencode/src/session/llm/request.ts index 4f93411107..3508ee981c 100644 --- a/packages/opencode/src/session/llm/request.ts +++ b/packages/opencode/src/session/llm/request.ts @@ -206,11 +206,11 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre }) function resolveTools(input: Pick) { - const disabled = Permission.disabled( - Object.keys(input.tools), + const visible = Permission.visibleTools( + input.tools, Permission.merge(input.agent.permission, input.permission ?? []), ) - return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k)) + return Record.filter(visible, (_, k) => input.user.tools?.[k] !== false) } export function hasToolCalls(messages: ModelMessage[]): boolean { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 6734a1f5ac..7fe4eefefa 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1237,6 +1237,8 @@ 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") { diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 376ba8f2b8..535cbb2f3d 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -3,6 +3,7 @@ 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" @@ -21,6 +22,7 @@ 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", @@ -52,6 +54,7 @@ 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, options: ToolExecutionOptions): Tool.Context => ({ sessionID: input.session.id, @@ -86,11 +89,20 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { .pipe(Effect.orDie), }) - for (const item of yield* registry.tools({ + 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({ 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, @@ -381,7 +393,9 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { }) } - for (const [key, item] of Object.entries(yield* mcp.tools())) { + if (codeMode) return tools + + for (const [key, item] of Object.entries(mcpTools)) { const execute = item.execute if (!execute) continue @@ -392,29 +406,19 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { run.promise( Effect.gen(function* () { const ctx = context(args, opts) - yield* plugin.trigger( - "tool.execute.before", - { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId }, - { args }, - ) - const result: Awaited>> = 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, - ) + // 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>> = yield* McpInvoke.invoke({ + plugin, + key, + execute, + args, + callID: opts.toolCallId, + options: opts, + sessionID: ctx.sessionID, + messageID: input.processor.message.id, + ask: ctx.ask, + }) const textParts: string[] = [] const attachments: Omit[] = [] diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 50a0d4d242..98224a6d1a 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -26,6 +26,9 @@ 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" @@ -51,6 +54,7 @@ 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 @@ -74,6 +78,7 @@ export interface Interface { providerID: ProviderV2.ID modelID: ModelV2.ID agent: Agent.Info + permission?: PermissionV1.Ruleset }) => Effect.Effect } @@ -87,6 +92,7 @@ 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 @@ -104,6 +110,7 @@ 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( @@ -211,6 +218,7 @@ const layer = Layer.effect( question: Tool.init(question), lsp: Tool.init(lsptool), plan: Tool.init(plan), + codemode: Tool.init(codemode), }) return { @@ -232,6 +240,7 @@ 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, @@ -263,11 +272,25 @@ 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") @@ -292,7 +315,11 @@ const layer = Layer.effect( : undefined return { id: tool.id, - description: [output.description, tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined] + description: [ + output.description, + tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined, + tool.id === CodeModeTool.id ? yield* describeCodeMode(input.agent, input.permission) : undefined, + ] .filter(Boolean) .join("\n"), parameters: output.parameters, @@ -314,6 +341,7 @@ const layer = Layer.effect( }), ) + function isZodType(value: unknown): value is z.ZodType { return typeof value === "object" && value !== null && "_zod" in value } @@ -406,6 +434,7 @@ export const node = LayerNode.make({ LSP.node, Instruction.node, FSUtil.node, + MCP.node, EventV2Bridge.node, httpClient, CrossSpawnSpawner.node, diff --git a/packages/opencode/test/session/tools.test.ts b/packages/opencode/test/session/tools.test.ts new file mode 100644 index 0000000000..8c8e439a52 --- /dev/null +++ b/packages/opencode/test/session/tools.test.ts @@ -0,0 +1,164 @@ +import { afterEach, describe, expect } from "bun:test" +import path from "path" +import { Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionTools } from "@/session/tools" +import { ToolRegistry } from "@/tool/registry" +import { Agent } from "@/agent/agent" +import { Config } from "@/config/config" +import { MCP } from "@/mcp" +import { McpCatalog } from "@/mcp/catalog" +import { Permission } from "@/permission" +import { Plugin } from "@/plugin" +import * as Truncate from "@/tool/truncate" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { InstanceState } from "@/effect/instance-state" +import { MessageID, SessionID } from "@/session/schema" +import { disposeAllInstances } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { TestConfig } from "../fixture/config" +import type { Tool as AITool } from "ai" + +const configLayer = TestConfig.layer({ + directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])), +}) + +// Fake MCP.Service built through the same `convertTool` path the real service uses, +// so the raw registration loop dispatches through a genuine ai-sdk execute. The fake +// client answers every call with a small text result. +function fakeMcpLayer(tools: Record) { + const client = { callTool: async () => ({ content: [{ type: "text", text: "ok" }] }) } + const converted: Record = Object.fromEntries( + Object.entries(tools).map(([key, def]) => [ + key, + McpCatalog.convertTool( + { + name: key, + description: def.description, + inputSchema: { type: "object", properties: {} }, + } as any, + client as any, + ), + ]), + ) + return Layer.mock(MCP.Service, { + tools: () => Effect.succeed(converted), + defs: () => Effect.succeed({}), + clients: () => Effect.succeed({ github: { getServerCapabilities: () => undefined } } as any), + }) +} + +const mcpToolsLayer = fakeMcpLayer({ + github_create_issue: { description: "Create an issue" }, + github_list_issues: { description: "List issues" }, +}) + +const root = LayerNode.group([ToolRegistry.node, Agent.node, MCP.node, RuntimeFlags.node]) +const withCodeMode = testEffect( + LayerNode.compile(root, [ + [Config.node, configLayer], + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalCodeMode: true })], + [MCP.node, mcpToolsLayer], + ]), +) +const withFlagOff = testEffect( + LayerNode.compile(root, [ + [Config.node, configLayer], + [RuntimeFlags.node, RuntimeFlags.layer()], + [MCP.node, mcpToolsLayer], + ]), +) + +// Resolve the session tool record with the smallest honest stand-ins for the pieces +// resolve reads: a fabricated model (only providerID/api.id are consulted by the +// schema transform for these fixtures), a pass-through Truncate, an always-allow +// Permission, and an optionally-observing Plugin.trigger. Registry, MCP, and flags +// come from the compiled instance context. +function resolveTools(trigger?: Plugin.Interface["trigger"]) { + return Effect.gen(function* () { + const agents = yield* Agent.Service + const agent = yield* agents.defaultInfo() + return yield* SessionTools.resolve({ + agent, + model: { providerID: "opencode", api: { id: "test" } } as any, + session: { id: SessionID.make("ses_session-tools"), permission: [] } as any, + processor: { + message: { id: MessageID.make("msg_session-tools") } as any, + updateToolCall: () => Effect.succeed(undefined), + completeToolCall: () => Effect.void, + }, + bypassAgentCheck: false, + messages: [], + promptOps: {} as any, + }) + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(Permission.Service, { ask: () => Effect.void }), + Layer.mock(Plugin.Service, { + trigger: + trigger ?? (((_name, _input, output) => Effect.succeed(output)) as Plugin.Interface["trigger"]), + }), + Layer.mock(Truncate.Service, { + output: (text: string) => Effect.succeed({ content: text, truncated: false as const }), + }), + ), + ), + ) +} + +afterEach(async () => { + await disposeAllInstances() +}) + +describe("session.tools resolve", () => { + withCodeMode.instance("code mode suppresses raw per-MCP registration behind the execute tool", () => + Effect.gen(function* () { + const tools = yield* resolveTools() + const ids = Object.keys(tools) + expect(ids).toContain("execute") + expect(ids).not.toContain("github_create_issue") + expect(ids).not.toContain("github_list_issues") + }), + ) + + withFlagOff.instance("without code mode the raw MCP tools register and execute is absent", () => + Effect.gen(function* () { + const tools = yield* resolveTools() + const ids = Object.keys(tools) + expect(ids).not.toContain("execute") + expect(ids).toContain("github_create_issue") + expect(ids).toContain("github_list_issues") + }), + ) + + withFlagOff.instance("legacy raw MCP execution fires plugin hooks keyed by the ai-sdk toolCallId", () => + Effect.gen(function* () { + const events: { name: string; input: any; output: any }[] = [] + const trigger = ((name: unknown, input: unknown, output: unknown) => + Effect.sync(() => { + events.push({ name: name as string, input, output }) + return output + })) as Plugin.Interface["trigger"] + const tools = yield* resolveTools(trigger) + const raw = tools["github_create_issue"]! + const result = yield* Effect.promise(() => + Promise.resolve( + raw.execute!( + { title: "x" }, + { toolCallId: "call_legacy", abortSignal: new AbortController().signal, messages: [] }, + ), + ), + ) + + expect((result as any).output).toBe("ok") + expect(events.map((e) => [e.name, e.input.tool, e.input.callID])).toEqual([ + ["tool.execute.before", "github_create_issue", "call_legacy"], + ["tool.execute.after", "github_create_issue", "call_legacy"], + ]) + expect(events[0]!.output).toEqual({ args: { title: "x" } }) + // The after hook receives the raw MCP result, before model-facing shaping. + expect(events[1]!.output).toMatchObject({ content: [{ type: "text", text: "ok" }] }) + }), + ) +}) diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 0e4b05f3e2..526576bfe6 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -17,6 +17,8 @@ import { InstanceState } from "@/effect/instance-state" import { ToolJsonSchema } from "@/tool/json-schema" import { MessageID, SessionID } from "@/session/schema" import { RuntimeFlags } from "@/effect/runtime-flags" +import { MCP } from "@/mcp" +import { McpCatalog } from "@/mcp/catalog" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" @@ -57,6 +59,45 @@ const replacements = [ const it = testEffect(LayerNode.compile(root, replacements)) const withBrokenPlugin = testEffect(LayerNode.compile(root, [...replacements, [Plugin.node, brokenPluginLayer]])) +// Fake MCP.Service serving two tools on one server, built through the same +// `convertTool` path the real service uses so the code-mode catalog renders +// genuine signatures. +function fakeMcpLayer(tools: Record) { + const client = { callTool: async () => ({ content: [] }) } + const converted = Object.fromEntries( + Object.entries(tools).map(([key, def]) => [ + key, + McpCatalog.convertTool( + { + name: key, + description: def.description, + inputSchema: { type: "object", properties: {} }, + } as any, + client as any, + ), + ]), + ) + return Layer.mock(MCP.Service, { + tools: () => Effect.succeed(converted), + defs: () => Effect.succeed({}), + clients: () => Effect.succeed(Object.keys(tools).length > 0 ? ({ github: {} } as any) : {}), + }) +} + +const mcpToolsLayer = fakeMcpLayer({ + github_create_issue: { description: "Create an issue" }, + github_list_issues: { description: "List issues" }, +}) + +const codeModeFlags = [RuntimeFlags.node, RuntimeFlags.layer({ experimentalCodeMode: true })] as const +const withCodeMode = testEffect( + LayerNode.compile(root, [[Config.node, configLayer], codeModeFlags, [MCP.node, mcpToolsLayer]]), +) +const withCodeModeNoMcp = testEffect( + LayerNode.compile(root, [[Config.node, configLayer], codeModeFlags, [MCP.node, fakeMcpLayer({})]]), +) +const withFlagOff = testEffect(LayerNode.compile(root, [...replacements, [MCP.node, mcpToolsLayer]])) + afterEach(async () => { await disposeAllInstances() }) @@ -491,3 +532,82 @@ describe("tool.registry", () => { }), ) }) + +describe("tool.registry code mode", () => { + const model = { providerID: ProviderV2.ID.opencode, modelID: ModelV2.ID.make("test") } + + withCodeMode.instance("registers the code-mode execute tool when the flag is on and MCP tools exist", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const agents = yield* Agent.Service + const tools = yield* registry.tools({ ...model, agent: yield* agents.defaultInfo() }) + const execute = tools.find((tool) => tool.id === "execute") + if (!execute) throw new Error("code-mode execute tool was not registered") + // The registry appends the grouped catalog to the static base description. + expect(execute.description).toContain("confined runtime") + expect(execute.description).toContain("## Available tools") + expect(execute.description).toContain("tools.github.create_issue(") + expect(execute.description).toContain("tools.github.list_issues(") + }), + ) + + withCodeModeNoMcp.instance("does not register code mode when no MCP tools are connected", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const agents = yield* Agent.Service + const tools = yield* registry.tools({ ...model, agent: yield* agents.defaultInfo() }) + expect(tools.map((tool) => tool.id)).not.toContain("execute") + }), + ) + + withFlagOff.instance("does not register code mode when the flag is off, even with MCP tools", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const agents = yield* Agent.Service + const tools = yield* registry.tools({ ...model, agent: yield* agents.defaultInfo() }) + expect(tools.map((tool) => tool.id)).not.toContain("execute") + }), + ) + + withCodeMode.instance("hard-denied MCP tools are dropped from the catalog; ask-level ones stay", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const agents = yield* Agent.Service + const base = yield* agents.defaultInfo() + const agent = { + ...base, + permission: [ + ...base.permission, + { permission: "github_create_issue", pattern: "*" as const, action: "deny" as const }, + { permission: "github_list_issues", pattern: "*" as const, action: "ask" as const }, + ], + } + const tools = yield* registry.tools({ ...model, agent }) + const execute = tools.find((tool) => tool.id === "execute") + if (!execute) throw new Error("code-mode execute tool was not registered") + expect(execute.description).not.toContain("create_issue") + expect(execute.description).toContain("tools.github.list_issues(") + expect(execute.description).toContain("- github (1 tool)") + }), + ) + + withCodeMode.instance("session-level hard-denied MCP tools are dropped from the code-mode catalog", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const agents = yield* Agent.Service + const tools = yield* registry.tools({ + ...model, + agent: yield* agents.defaultInfo(), + permission: [ + { permission: "github_create_issue", pattern: "*" as const, action: "deny" as const }, + { permission: "github_list_issues", pattern: "*" as const, action: "ask" as const }, + ], + }) + const execute = tools.find((tool) => tool.id === "execute") + if (!execute) throw new Error("code-mode execute tool was not registered") + expect(execute.description).not.toContain("create_issue") + expect(execute.description).toContain("tools.github.list_issues(") + expect(execute.description).toContain("- github (1 tool)") + }), + ) +})