Compare commits

...

1 Commits

Author SHA1 Message Date
Aiden Cline caf2d59d7f feat(opencode): expose code-mode execute behind experimental flag 2026-07-03 00:29:54 -05:00
7 changed files with 350 additions and 30 deletions
@@ -43,6 +43,7 @@ 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"),
+3 -3
View File
@@ -206,11 +206,11 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre
})
function resolveTools(input: Pick<PrepareInput, "tools" | "agent" | "permission" | "user">) {
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 {
+2
View File
@@ -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") {
+30 -26
View File
@@ -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<string, unknown>, 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<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,
)
// 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,
})
const textParts: string[] = []
const attachments: Omit<SessionV1.FilePart, "id" | "sessionID" | "messageID">[] = []
+30 -1
View File
@@ -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<Tool.Def[]>
}
@@ -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<State>(
@@ -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,
@@ -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<string, { description: string }>) {
const client = { callTool: async () => ({ content: [{ type: "text", text: "ok" }] }) }
const converted: Record<string, AITool> = 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" }] })
}),
)
})
@@ -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<string, { description: string }>) {
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)")
}),
)
})