feat(opencode): gate execute tool behind code mode flag (#35185)

This commit is contained in:
Aiden Cline 2026-07-03 13:41:15 -05:00 committed by GitHub
parent 96d53c6716
commit ed6dc879be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 174 additions and 15 deletions

View file

@ -458,8 +458,8 @@ export const discoveryPlan = <R>(
// Section order is deliberate: workflow first (the top is the least likely part of a long
// description to be truncated or skimmed away), then rules, then syntax, with the budgeted
// catalog at the bottom. Example call forms use explicit `<namespace>.<tool>` placeholders -
// never a real or fabricated tool name.
// catalog at the bottom. Example call forms use placeholders - never a real or fabricated
// tool name - and show both dot and bracket notation so non-identifier names are not normalized.
const intro = [
"Write a CodeMode program to answer the request. Return code only.",
empty
@ -467,6 +467,7 @@ export const discoveryPlan = <R>(
: complete
? "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."
: "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.",
...(empty ? [] : ["Do not infer or normalize tool names; use only exact signatures shown below or returned by search."]),
]
// The search step exists only when search is advertised (PARTIAL catalog); a COMPLETE
@ -480,14 +481,14 @@ export const discoveryPlan = <R>(
...(complete
? [
"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.",
"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.",
'2. Call it using the exact signature shown; bracket notation and quotes are part of the path.',
'3. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string.',
"4. Return only the fields you need: `return { <field>: data.<field> }` - raw payloads get truncated and waste context.",
]
: [
'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.',
'1. If the exact signature is not listed below, first search: `const { items } = await tools.$codemode.search({ query: "<intent + key nouns>" })`.',
"2. Read the matches: each item is `{ path, description, signature }` - read the description before using an unfamiliar tool.",
"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.",
"3. Call the result's `path` as-is; bracket notation and quotes are part of the path.",
'4. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string.',
"5. Return only the fields you need: `return { <field>: data.<field> }` - raw payloads get truncated and waste context.",
]),
@ -504,7 +505,7 @@ export const discoveryPlan = <R>(
: "- Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`; tools from the surrounding agent/runtime are not implicitly exposed.",
"- Filter, aggregate, and transform collections in code - never return them raw or call a tool per item across messages.",
"- A result typed `Promise<unknown>` has no guaranteed shape - verify what actually came back before relying on its fields.",
"- Run independent calls in parallel: `await Promise.all(items.map((item) => tools.<namespace>.<tool>(item)))`.",
'- 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.',
"- `Object.keys(tools)` lists namespaces; `Object.keys(tools.<namespace>)` lists its tools; `for...in` works on both.",
...(complete
? []

View file

@ -622,12 +622,12 @@ describe("CodeMode public contract", () => {
)
expect(instructions).toContain("Return only the fields you need")
expect(instructions).toContain("raw payloads get truncated and waste context")
expect(instructions).toContain("`const res = await tools.<namespace>.<tool>(input)`")
expect(instructions).toContain("Do not infer or normalize tool names")
expect(instructions).toContain("bracket notation and quotes are part of the path")
expect(instructions).toContain("surrounding agent tools are not available unless listed here")
expect(instructions).toContain("Only tools listed here are available inside `tools`")
expect(instructions).toContain("bracket notation may appear for names that are not JavaScript identifiers")
// Placeholders use the <namespace>.<tool>/<field> style ONLY - no fabricated tool
// names, and no real catalog tools cherry-picked into example lines.
// Placeholders use generic namespace/tool/field names only - no fabricated real tools
// and no real catalog tools cherry-picked into example lines.
expect(instructions).toContain("`return { <field>: data.<field> }`")
expect(instructions).not.toContain("total_count")
expect(instructions).not.toContain("list_issues")
@ -640,7 +640,7 @@ describe("CodeMode public contract", () => {
// PARTIAL: the workflow starts with search (with query-style guidance that is clearly
// a query string, never a tool name) and the browse-namespace rule appears.
expect(partial).toContain(
'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.',
'1. If the exact signature is not listed below, first search: `const { items } = await tools.$codemode.search({ query: "<intent + key nouns>" })`.',
)
expect(partial).toContain(
"Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`",

View file

@ -339,3 +339,43 @@ describe("pretty signatures in search results", () => {
expect(instructions).not.toContain("/**")
})
})
describe("non-identifier tool paths", () => {
const resolveLibrary = Tool.make({
description: "Resolve a Context7 library ID",
input: {
type: "object",
properties: {
query: { type: "string" },
libraryName: { type: "string" },
},
required: ["query", "libraryName"],
} as const,
run: () => Effect.succeed("/reactjs/react.dev"),
})
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
test("inline catalog uses bracket notation for dashed tool names", () => {
const instructions = runtime.instructions()
expect(instructions).toContain(
'tools.context7["resolve-library-id"](input: { query: string; libraryName: string }): Promise<unknown>',
)
expect(instructions).toContain("Do not infer or normalize tool names")
expect(instructions).toContain("bracket notation and quotes are part of the path")
expect(instructions).not.toContain("tools.context7.resolve-library-id")
expect(instructions).not.toContain("tools.context7.resolve_library_id")
})
test("search results return callable bracket-notation paths and signatures", async () => {
const result = await Effect.runPromise(
runtime.execute(`return await tools.$codemode.search({ query: "resolve library" })`),
)
expect(result.ok).toBe(true)
if (!result.ok) throw new Error("search failed")
const value = result.value as { items: Array<{ path: string; signature: string }> }
expect(value.items[0]?.path).toBe('tools.context7["resolve-library-id"]')
expect(value.items[0]?.signature).toContain('tools.context7["resolve-library-id"](input: {')
})
})

View file

@ -45,6 +45,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
experimentalOxfmt: enabledByExperimental("OPENCODE_EXPERIMENTAL_OXFMT"),
experimentalPlanMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
experimentalCodeMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_CODE_MODE"),
experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
experimentalWorkspaces: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),

View file

@ -1237,6 +1237,7 @@ const layer = Layer.effect(
Effect.provideService(ToolRegistry.Service, registry),
Effect.provideService(MCP.Service, mcp),
Effect.provideService(Truncate.Service, truncate),
Effect.provideService(RuntimeFlags.Service, flags),
)
if (lastUser.format?.type === "json_schema") {

View file

@ -22,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",
@ -53,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,
@ -91,6 +93,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
modelID: ModelV2.ID.make(input.model.api.id),
providerID: input.model.providerID,
agent: input.agent,
permission: input.session.permission,
})) {
const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
tools[item.id] = tool({
@ -382,6 +385,8 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
})
}
if (flags.experimentalCodeMode) return tools
for (const [key, entry] of Object.entries(yield* mcp.tools())) {
const item = McpCatalog.convertTool(entry.def, entry.client, entry.timeout)
const execute = item.execute

View file

@ -51,6 +51,9 @@ 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 { MCP } from "@/mcp"
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { McpCatalog } from "@/mcp/catalog"
export function webSearchEnabled(providerID: ProviderV2.ID, flags = { exa: false, parallel: false }) {
return providerID === ProviderV2.ID.opencode || flags.exa || flags.parallel
@ -74,6 +77,7 @@ export interface Interface {
providerID: ProviderV2.ID
modelID: ModelV2.ID
agent: Agent.Info
permission?: PermissionV1.Ruleset
}) => Effect.Effect<Tool.Def[]>
}
@ -87,6 +91,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
@ -105,6 +110,8 @@ const layer = Layer.effect(
const patchtool = yield* ApplyPatchTool
const skilltool = yield* SkillTool
const agent = yield* Agent.Service
const codeMode = flags.experimentalCodeMode ? yield* Effect.promise(() => import("./code-mode")) : undefined
const codeModeTool = codeMode ? yield* codeMode.CodeModeTool : undefined
const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) {
@ -211,6 +218,7 @@ const layer = Layer.effect(
question: Tool.init(question),
lsp: Tool.init(lsptool),
plan: Tool.init(plan),
...(codeModeTool ? { execute: Tool.init(codeModeTool) } : {}),
})
return {
@ -230,6 +238,7 @@ const layer = Layer.effect(
tool.search,
tool.skill,
tool.patch,
...(tool.execute ? [tool.execute] : []),
...(flags.experimentalLspTool ? [tool.lsp] : []),
...(flags.experimentalPlanMode && flags.client === "cli" ? [tool.plan] : []),
],
@ -263,6 +272,20 @@ const layer = Layer.effect(
return ["Available agent types and the tools they have access to:", description].join("\n")
})
const describeCodeMode = Effect.fn("ToolRegistry.describeCodeMode")(function* (input: {
agent: Agent.Info
permission?: PermissionV1.Ruleset
}) {
if (!codeMode) return
const ruleset = Permission.merge(input.agent.permission, input.permission ?? [])
const tools = Permission.visibleTools(yield* mcp.tools(), ruleset)
if (Object.keys(tools).length === 0) return
return codeMode.describeCatalog(
tools,
Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize),
)
})
const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
const filtered = (yield* all()).filter((tool) => {
if (tool.id === WebSearchTool.id) {
@ -277,8 +300,11 @@ const layer = Layer.effect(
return true
})
const codeModeDescription = filtered.some((tool) => tool.id === "execute") ? yield* describeCodeMode(input) : undefined
const visible = filtered.filter((tool) => tool.id !== "execute" || codeModeDescription)
return yield* Effect.forEach(
filtered,
visible,
Effect.fnUntraced(function* (tool: Tool.Def) {
const output = {
description: tool.description,
@ -292,7 +318,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 === "execute" ? codeModeDescription : undefined,
]
.filter(Boolean)
.join("\n"),
parameters: output.parameters,
@ -412,6 +442,7 @@ export const node = LayerNode.make({
Format.node,
Truncate.node,
RuntimeFlags.node,
MCP.node,
Database.node,
Ripgrep.node,
],

View file

@ -182,7 +182,8 @@ describe("code mode integration (real MCP server)", () => {
expect(description).toContain("// Add two numbers and return the structured sum")
expect(description).not.toContain("$codemode")
expect(description).toContain("## Workflow")
expect(description).toContain("`const res = await tools.<namespace>.<tool>(input)`")
expect(description).toContain("Do not infer or normalize tool names")
expect(description).toContain("bracket notation and quotes are part of the path")
expect(description).not.toContain("total_count")
})

View file

@ -209,7 +209,7 @@ describe("code mode execute", () => {
expect(description).toContain("- zeta (1 tool)\n")
expect(description).toContain("tools.zeta.only_tool(input: { topic: string }): Promise<unknown>")
expect(description).toContain("tools.$codemode.search(")
expect(description).toContain("1. Find a tool (skip when it is already listed below)")
expect(description).toContain("1. If the exact signature is not listed below, first search:")
expect(description).toContain(
'- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.',
)

View file

@ -19,6 +19,8 @@ import { MessageID, SessionID } from "@/session/schema"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { MCP } from "@/mcp"
import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js"
const configLayer = TestConfig.layer({
directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])),
@ -55,6 +57,42 @@ const replacements = [
] as const
const it = testEffect(LayerNode.compile(root, replacements))
const withCodeMode = testEffect(
LayerNode.compile(root, [
[Config.node, configLayer],
[RuntimeFlags.node, RuntimeFlags.layer({ experimentalCodeMode: true })],
[
MCP.node,
Layer.mock(MCP.Service, {
tools: () =>
Effect.succeed({
weather_current: {
def: {
name: "current",
description: "current weather",
inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
} as MCPToolDef,
client: {} as MCP.McpTool["client"],
},
}),
clients: () => Effect.succeed({ weather: {} as any }),
}),
],
]),
)
const withEmptyCodeMode = testEffect(
LayerNode.compile(root, [
[Config.node, configLayer],
[RuntimeFlags.node, RuntimeFlags.layer({ experimentalCodeMode: true })],
[
MCP.node,
Layer.mock(MCP.Service, {
tools: () => Effect.succeed({}),
clients: () => Effect.succeed({}),
}),
],
]),
)
const withBrokenPlugin = testEffect(LayerNode.compile(root, [...replacements, [Plugin.node, brokenPluginLayer]]))
afterEach(async () => {
@ -71,6 +109,47 @@ describe("tool.registry", () => {
}),
)
it.instance("does not expose execute unless code mode is enabled", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).not.toContain("execute")
}),
)
withCodeMode.instance("exposes execute when code mode is enabled", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const agents = yield* Agent.Service
const ids = yield* registry.ids()
const tools = yield* registry.tools({
providerID: ProviderV2.ID.opencode,
modelID: ModelV2.ID.make("test"),
agent: yield* agents.defaultInfo(),
})
const execute = tools.find((tool) => tool.id === "execute")
expect(ids).toContain("execute")
expect(tools.map((tool) => tool.id)).toContain("execute")
expect(execute?.description).toContain("tools.weather.current(input: { city: string })")
}),
)
withEmptyCodeMode.instance("does not expose execute when code mode has no visible tools", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const agents = yield* Agent.Service
const tools = yield* registry.tools({
providerID: ProviderV2.ID.opencode,
modelID: ModelV2.ID.make("test"),
agent: yield* agents.defaultInfo(),
})
expect(tools.map((tool) => tool.id)).not.toContain("execute")
}),
)
it.instance("hides task background parameter unless experimental background subagents are enabled", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service