Просмотр исходного кода

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

Aiden Cline 2 месяцев назад
Родитель
Сommit
ed6dc879be

+ 7 - 6
packages/codemode/src/tool-runtime.ts

@@ -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
           ? []

+ 5 - 5
packages/codemode/test/codemode.test.ts

@@ -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`",

+ 40 - 0
packages/codemode/test/signature.test.ts

@@ -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: {')
+  })
+})

+ 1 - 0
packages/opencode/src/effect/runtime-flags.ts

@@ -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"),

+ 1 - 0
packages/opencode/src/session/prompt.ts

@@ -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") {

+ 5 - 0
packages/opencode/src/session/tools.ts

@@ -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

+ 33 - 2
packages/opencode/src/tool/registry.ts

@@ -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,
   ],

+ 2 - 1
packages/opencode/test/tool/code-mode-integration.test.ts

@@ -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")
   })
 

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

@@ -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>" })`.',
     )

+ 79 - 0
packages/opencode/test/tool/registry.test.ts

@@ -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