Skip to content

Commit ed6dc87

Browse files
authored
feat(opencode): gate execute tool behind code mode flag (#35185)
1 parent 96d53c6 commit ed6dc87

10 files changed

Lines changed: 174 additions & 15 deletions

File tree

packages/codemode/src/tool-runtime.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -458,15 +458,16 @@ export const discoveryPlan = <R>(
458458

459459
// Section order is deliberate: workflow first (the top is the least likely part of a long
460460
// description to be truncated or skimmed away), then rules, then syntax, with the budgeted
461-
// catalog at the bottom. Example call forms use explicit `<namespace>.<tool>` placeholders -
462-
// never a real or fabricated tool name.
461+
// catalog at the bottom. Example call forms use placeholders - never a real or fabricated
462+
// tool name - and show both dot and bracket notation so non-identifier names are not normalized.
463463
const intro = [
464464
"Write a CodeMode program to answer the request. Return code only.",
465465
empty
466466
? "Execute JavaScript in a confined runtime."
467467
: complete
468468
? "Execute JavaScript in a confined runtime. Inside this program, `tools` contains only the host-provided tools listed below; surrounding agent tools are not available unless listed here."
469469
: "Execute JavaScript in a confined runtime. Inside this program, `tools` contains only the host-provided tools listed or searchable below; surrounding agent tools are not available unless listed here.",
470+
...(empty ? [] : ["Do not infer or normalize tool names; use only exact signatures shown below or returned by search."]),
470471
]
471472

472473
// The search step exists only when search is advertised (PARTIAL catalog); a COMPLETE
@@ -480,14 +481,14 @@ export const discoveryPlan = <R>(
480481
...(complete
481482
? [
482483
"1. Pick a tool from the list under `## Available tools` - each line is the exact call signature; use it as-is rather than guessing segments.",
483-
"2. Call it using the exact signature shown: `const res = await tools.<namespace>.<tool>(input)` - bracket notation may appear for names that are not JavaScript identifiers.",
484+
'2. Call it using the exact signature shown; bracket notation and quotes are part of the path.',
484485
'3. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string.',
485486
"4. Return only the fields you need: `return { <field>: data.<field> }` - raw payloads get truncated and waste context.",
486487
]
487488
: [
488-
'1. Find a tool (skip when it is already listed below): `const { items } = await tools.$codemode.search({ query: "<intent + key nouns>" })` - short phrases like "list issues" work best.',
489+
'1. If the exact signature is not listed below, first search: `const { items } = await tools.$codemode.search({ query: "<intent + key nouns>" })`.',
489490
"2. Read the matches: each item is `{ path, description, signature }` - read the description before using an unfamiliar tool.",
490-
"3. Call it with the result's `path` as-is (never guess segments): `const res = await tools.<namespace>.<tool>(input)` - bracket notation may appear for names that are not JavaScript identifiers.",
491+
"3. Call the result's `path` as-is; bracket notation and quotes are part of the path.",
491492
'4. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string.',
492493
"5. Return only the fields you need: `return { <field>: data.<field> }` - raw payloads get truncated and waste context.",
493494
]),
@@ -504,7 +505,7 @@ export const discoveryPlan = <R>(
504505
: "- Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`; tools from the surrounding agent/runtime are not implicitly exposed.",
505506
"- Filter, aggregate, and transform collections in code - never return them raw or call a tool per item across messages.",
506507
"- A result typed `Promise<unknown>` has no guaranteed shape - verify what actually came back before relying on its fields.",
507-
"- Run independent calls in parallel: `await Promise.all(items.map((item) => tools.<namespace>.<tool>(item)))`.",
508+
'- Run independent calls in parallel: `await Promise.all(items.map((item) => tools.<namespace>.<tool>(item)))`, or use `tools.<namespace>["tool-name"](item)` when the listed signature uses bracket notation.',
508509
"- `Object.keys(tools)` lists namespaces; `Object.keys(tools.<namespace>)` lists its tools; `for...in` works on both.",
509510
...(complete
510511
? []

packages/codemode/test/codemode.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -622,12 +622,12 @@ describe("CodeMode public contract", () => {
622622
)
623623
expect(instructions).toContain("Return only the fields you need")
624624
expect(instructions).toContain("raw payloads get truncated and waste context")
625-
expect(instructions).toContain("`const res = await tools.<namespace>.<tool>(input)`")
625+
expect(instructions).toContain("Do not infer or normalize tool names")
626+
expect(instructions).toContain("bracket notation and quotes are part of the path")
626627
expect(instructions).toContain("surrounding agent tools are not available unless listed here")
627628
expect(instructions).toContain("Only tools listed here are available inside `tools`")
628-
expect(instructions).toContain("bracket notation may appear for names that are not JavaScript identifiers")
629-
// Placeholders use the <namespace>.<tool>/<field> style ONLY - no fabricated tool
630-
// names, and no real catalog tools cherry-picked into example lines.
629+
// Placeholders use generic namespace/tool/field names only - no fabricated real tools
630+
// and no real catalog tools cherry-picked into example lines.
631631
expect(instructions).toContain("`return { <field>: data.<field> }`")
632632
expect(instructions).not.toContain("total_count")
633633
expect(instructions).not.toContain("list_issues")
@@ -640,7 +640,7 @@ describe("CodeMode public contract", () => {
640640
// PARTIAL: the workflow starts with search (with query-style guidance that is clearly
641641
// a query string, never a tool name) and the browse-namespace rule appears.
642642
expect(partial).toContain(
643-
'1. Find a tool (skip when it is already listed below): `const { items } = await tools.$codemode.search({ query: "<intent + key nouns>" })` - short phrases like "list issues" work best.',
643+
'1. If the exact signature is not listed below, first search: `const { items } = await tools.$codemode.search({ query: "<intent + key nouns>" })`.',
644644
)
645645
expect(partial).toContain(
646646
"Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`",

packages/codemode/test/signature.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,3 +339,43 @@ describe("pretty signatures in search results", () => {
339339
expect(instructions).not.toContain("/**")
340340
})
341341
})
342+
343+
describe("non-identifier tool paths", () => {
344+
const resolveLibrary = Tool.make({
345+
description: "Resolve a Context7 library ID",
346+
input: {
347+
type: "object",
348+
properties: {
349+
query: { type: "string" },
350+
libraryName: { type: "string" },
351+
},
352+
required: ["query", "libraryName"],
353+
} as const,
354+
run: () => Effect.succeed("/reactjs/react.dev"),
355+
})
356+
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
357+
358+
test("inline catalog uses bracket notation for dashed tool names", () => {
359+
const instructions = runtime.instructions()
360+
361+
expect(instructions).toContain(
362+
'tools.context7["resolve-library-id"](input: { query: string; libraryName: string }): Promise<unknown>',
363+
)
364+
expect(instructions).toContain("Do not infer or normalize tool names")
365+
expect(instructions).toContain("bracket notation and quotes are part of the path")
366+
expect(instructions).not.toContain("tools.context7.resolve-library-id")
367+
expect(instructions).not.toContain("tools.context7.resolve_library_id")
368+
})
369+
370+
test("search results return callable bracket-notation paths and signatures", async () => {
371+
const result = await Effect.runPromise(
372+
runtime.execute(`return await tools.$codemode.search({ query: "resolve library" })`),
373+
)
374+
expect(result.ok).toBe(true)
375+
if (!result.ok) throw new Error("search failed")
376+
377+
const value = result.value as { items: Array<{ path: string; signature: string }> }
378+
expect(value.items[0]?.path).toBe('tools.context7["resolve-library-id"]')
379+
expect(value.items[0]?.signature).toContain('tools.context7["resolve-library-id"](input: {')
380+
})
381+
})

packages/opencode/src/effect/runtime-flags.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
4545
experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
4646
experimentalOxfmt: enabledByExperimental("OPENCODE_EXPERIMENTAL_OXFMT"),
4747
experimentalPlanMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
48+
experimentalCodeMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_CODE_MODE"),
4849
experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
4950
experimentalWorkspaces: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
5051
experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),

packages/opencode/src/session/prompt.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1237,6 +1237,7 @@ const layer = Layer.effect(
12371237
Effect.provideService(ToolRegistry.Service, registry),
12381238
Effect.provideService(MCP.Service, mcp),
12391239
Effect.provideService(Truncate.Service, truncate),
1240+
Effect.provideService(RuntimeFlags.Service, flags),
12401241
)
12411242

12421243
if (lastUser.format?.type === "json_schema") {

packages/opencode/src/session/tools.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { EffectBridge } from "@/effect/bridge"
2222
import { ProviderV2 } from "@opencode-ai/core/provider"
2323
import { ModelV2 } from "@opencode-ai/core/model"
2424
import { isRecord } from "@/util/record"
25+
import { RuntimeFlags } from "@/effect/runtime-flags"
2526

2627
const MCP_RESOURCE_TOOLS = {
2728
list: "list_mcp_resources",
@@ -53,6 +54,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
5354
const registry = yield* ToolRegistry.Service
5455
const mcp = yield* MCP.Service
5556
const truncate = yield* Truncate.Service
57+
const flags = yield* RuntimeFlags.Service
5658

5759
const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({
5860
sessionID: input.session.id,
@@ -91,6 +93,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
9193
modelID: ModelV2.ID.make(input.model.api.id),
9294
providerID: input.model.providerID,
9395
agent: input.agent,
96+
permission: input.session.permission,
9497
})) {
9598
const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
9699
tools[item.id] = tool({
@@ -382,6 +385,8 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
382385
})
383386
}
384387

388+
if (flags.experimentalCodeMode) return tools
389+
385390
for (const [key, entry] of Object.entries(yield* mcp.tools())) {
386391
const item = McpCatalog.convertTool(entry.def, entry.client, entry.timeout)
387392
const execute = item.execute

packages/opencode/src/tool/registry.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ import { BackgroundJob } from "@/background/job"
5151
import { RuntimeFlags } from "@/effect/runtime-flags"
5252
import { ProviderV2 } from "@opencode-ai/core/provider"
5353
import { ModelV2 } from "@opencode-ai/core/model"
54+
import { MCP } from "@/mcp"
55+
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
56+
import { McpCatalog } from "@/mcp/catalog"
5457

5558
export function webSearchEnabled(providerID: ProviderV2.ID, flags = { exa: false, parallel: false }) {
5659
return providerID === ProviderV2.ID.opencode || flags.exa || flags.parallel
@@ -74,6 +77,7 @@ export interface Interface {
7477
providerID: ProviderV2.ID
7578
modelID: ModelV2.ID
7679
agent: Agent.Info
80+
permission?: PermissionV1.Ruleset
7781
}) => Effect.Effect<Tool.Def[]>
7882
}
7983

@@ -87,6 +91,7 @@ const layer = Layer.effect(
8791
const agents = yield* Agent.Service
8892
const truncate = yield* Truncate.Service
8993
const flags = yield* RuntimeFlags.Service
94+
const mcp = yield* MCP.Service
9095

9196
const invalid = yield* InvalidTool
9297
const task = yield* TaskTool
@@ -105,6 +110,8 @@ const layer = Layer.effect(
105110
const patchtool = yield* ApplyPatchTool
106111
const skilltool = yield* SkillTool
107112
const agent = yield* Agent.Service
113+
const codeMode = flags.experimentalCodeMode ? yield* Effect.promise(() => import("./code-mode")) : undefined
114+
const codeModeTool = codeMode ? yield* codeMode.CodeModeTool : undefined
108115

109116
const state = yield* InstanceState.make<State>(
110117
Effect.fn("ToolRegistry.state")(function* (ctx) {
@@ -211,6 +218,7 @@ const layer = Layer.effect(
211218
question: Tool.init(question),
212219
lsp: Tool.init(lsptool),
213220
plan: Tool.init(plan),
221+
...(codeModeTool ? { execute: Tool.init(codeModeTool) } : {}),
214222
})
215223

216224
return {
@@ -230,6 +238,7 @@ const layer = Layer.effect(
230238
tool.search,
231239
tool.skill,
232240
tool.patch,
241+
...(tool.execute ? [tool.execute] : []),
233242
...(flags.experimentalLspTool ? [tool.lsp] : []),
234243
...(flags.experimentalPlanMode && flags.client === "cli" ? [tool.plan] : []),
235244
],
@@ -263,6 +272,20 @@ const layer = Layer.effect(
263272
return ["Available agent types and the tools they have access to:", description].join("\n")
264273
})
265274

275+
const describeCodeMode = Effect.fn("ToolRegistry.describeCodeMode")(function* (input: {
276+
agent: Agent.Info
277+
permission?: PermissionV1.Ruleset
278+
}) {
279+
if (!codeMode) return
280+
const ruleset = Permission.merge(input.agent.permission, input.permission ?? [])
281+
const tools = Permission.visibleTools(yield* mcp.tools(), ruleset)
282+
if (Object.keys(tools).length === 0) return
283+
return codeMode.describeCatalog(
284+
tools,
285+
Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize),
286+
)
287+
})
288+
266289
const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
267290
const filtered = (yield* all()).filter((tool) => {
268291
if (tool.id === WebSearchTool.id) {
@@ -277,8 +300,11 @@ const layer = Layer.effect(
277300
return true
278301
})
279302

303+
const codeModeDescription = filtered.some((tool) => tool.id === "execute") ? yield* describeCodeMode(input) : undefined
304+
const visible = filtered.filter((tool) => tool.id !== "execute" || codeModeDescription)
305+
280306
return yield* Effect.forEach(
281-
filtered,
307+
visible,
282308
Effect.fnUntraced(function* (tool: Tool.Def) {
283309
const output = {
284310
description: tool.description,
@@ -292,7 +318,11 @@ const layer = Layer.effect(
292318
: undefined
293319
return {
294320
id: tool.id,
295-
description: [output.description, tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined]
321+
description: [
322+
output.description,
323+
tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined,
324+
tool.id === "execute" ? codeModeDescription : undefined,
325+
]
296326
.filter(Boolean)
297327
.join("\n"),
298328
parameters: output.parameters,
@@ -412,6 +442,7 @@ export const node = LayerNode.make({
412442
Format.node,
413443
Truncate.node,
414444
RuntimeFlags.node,
445+
MCP.node,
415446
Database.node,
416447
Ripgrep.node,
417448
],

packages/opencode/test/tool/code-mode-integration.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,8 @@ describe("code mode integration (real MCP server)", () => {
182182
expect(description).toContain("// Add two numbers and return the structured sum")
183183
expect(description).not.toContain("$codemode")
184184
expect(description).toContain("## Workflow")
185-
expect(description).toContain("`const res = await tools.<namespace>.<tool>(input)`")
185+
expect(description).toContain("Do not infer or normalize tool names")
186+
expect(description).toContain("bracket notation and quotes are part of the path")
186187
expect(description).not.toContain("total_count")
187188
})
188189

packages/opencode/test/tool/code-mode.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ describe("code mode execute", () => {
209209
expect(description).toContain("- zeta (1 tool)\n")
210210
expect(description).toContain("tools.zeta.only_tool(input: { topic: string }): Promise<unknown>")
211211
expect(description).toContain("tools.$codemode.search(")
212-
expect(description).toContain("1. Find a tool (skip when it is already listed below)")
212+
expect(description).toContain("1. If the exact signature is not listed below, first search:")
213213
expect(description).toContain(
214214
'- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.',
215215
)

packages/opencode/test/tool/registry.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import { MessageID, SessionID } from "@/session/schema"
1919
import { RuntimeFlags } from "@/effect/runtime-flags"
2020
import { ProviderV2 } from "@opencode-ai/core/provider"
2121
import { ModelV2 } from "@opencode-ai/core/model"
22+
import { MCP } from "@/mcp"
23+
import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js"
2224

2325
const configLayer = TestConfig.layer({
2426
directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])),
@@ -55,6 +57,42 @@ const replacements = [
5557
] as const
5658

5759
const it = testEffect(LayerNode.compile(root, replacements))
60+
const withCodeMode = testEffect(
61+
LayerNode.compile(root, [
62+
[Config.node, configLayer],
63+
[RuntimeFlags.node, RuntimeFlags.layer({ experimentalCodeMode: true })],
64+
[
65+
MCP.node,
66+
Layer.mock(MCP.Service, {
67+
tools: () =>
68+
Effect.succeed({
69+
weather_current: {
70+
def: {
71+
name: "current",
72+
description: "current weather",
73+
inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
74+
} as MCPToolDef,
75+
client: {} as MCP.McpTool["client"],
76+
},
77+
}),
78+
clients: () => Effect.succeed({ weather: {} as any }),
79+
}),
80+
],
81+
]),
82+
)
83+
const withEmptyCodeMode = testEffect(
84+
LayerNode.compile(root, [
85+
[Config.node, configLayer],
86+
[RuntimeFlags.node, RuntimeFlags.layer({ experimentalCodeMode: true })],
87+
[
88+
MCP.node,
89+
Layer.mock(MCP.Service, {
90+
tools: () => Effect.succeed({}),
91+
clients: () => Effect.succeed({}),
92+
}),
93+
],
94+
]),
95+
)
5896
const withBrokenPlugin = testEffect(LayerNode.compile(root, [...replacements, [Plugin.node, brokenPluginLayer]]))
5997

6098
afterEach(async () => {
@@ -71,6 +109,47 @@ describe("tool.registry", () => {
71109
}),
72110
)
73111

112+
it.instance("does not expose execute unless code mode is enabled", () =>
113+
Effect.gen(function* () {
114+
const registry = yield* ToolRegistry.Service
115+
const ids = yield* registry.ids()
116+
117+
expect(ids).not.toContain("execute")
118+
}),
119+
)
120+
121+
withCodeMode.instance("exposes execute when code mode is enabled", () =>
122+
Effect.gen(function* () {
123+
const registry = yield* ToolRegistry.Service
124+
const agents = yield* Agent.Service
125+
const ids = yield* registry.ids()
126+
const tools = yield* registry.tools({
127+
providerID: ProviderV2.ID.opencode,
128+
modelID: ModelV2.ID.make("test"),
129+
agent: yield* agents.defaultInfo(),
130+
})
131+
const execute = tools.find((tool) => tool.id === "execute")
132+
133+
expect(ids).toContain("execute")
134+
expect(tools.map((tool) => tool.id)).toContain("execute")
135+
expect(execute?.description).toContain("tools.weather.current(input: { city: string })")
136+
}),
137+
)
138+
139+
withEmptyCodeMode.instance("does not expose execute when code mode has no visible tools", () =>
140+
Effect.gen(function* () {
141+
const registry = yield* ToolRegistry.Service
142+
const agents = yield* Agent.Service
143+
const tools = yield* registry.tools({
144+
providerID: ProviderV2.ID.opencode,
145+
modelID: ModelV2.ID.make("test"),
146+
agent: yield* agents.defaultInfo(),
147+
})
148+
149+
expect(tools.map((tool) => tool.id)).not.toContain("execute")
150+
}),
151+
)
152+
74153
it.instance("hides task background parameter unless experimental background subagents are enabled", () =>
75154
Effect.gen(function* () {
76155
const registry = yield* ToolRegistry.Service

0 commit comments

Comments
 (0)