diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index fa3ddc6c2f..d79d0182b5 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -458,8 +458,8 @@ export const discoveryPlan = ( // 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 `.` 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 = ( : 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 = ( ...(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..(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 { : data. }` - 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: "" })` - 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: "" })`.', "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..(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 { : data. }` - raw payloads get truncated and waste context.", ]), @@ -504,7 +505,7 @@ export const discoveryPlan = ( : "- 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` 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..(item)))`.", + '- Run independent calls in parallel: `await Promise.all(items.map((item) => tools..(item)))`, or use `tools.["tool-name"](item)` when the listed signature uses bracket notation.', "- `Object.keys(tools)` lists namespaces; `Object.keys(tools.)` lists its tools; `for...in` works on both.", ...(complete ? [] diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index eaa834e0a4..4fda88b384 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/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..(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 ./ 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 { : data. }`") 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: "" })` - 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: "" })`.', ) expect(partial).toContain( "Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`", diff --git a/packages/codemode/test/signature.test.ts b/packages/codemode/test/signature.test.ts index 9c45371d93..edf4c442c4 100644 --- a/packages/codemode/test/signature.test.ts +++ b/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', + ) + 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: {') + }) +}) diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index 4baf867db6..65e02f0763 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -45,6 +45,7 @@ export class Service extends ConfigService.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"), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 6734a1f5ac..eb116f6b96 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/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") { diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 449e83f32b..0f401c7562 100644 --- a/packages/opencode/src/session/tools.ts +++ b/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, 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 diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 50a0d4d242..d933fd0105 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/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 } @@ -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( 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, ], diff --git a/packages/opencode/test/tool/code-mode-integration.test.ts b/packages/opencode/test/tool/code-mode-integration.test.ts index 6277a20a27..f36b6c866e 100644 --- a/packages/opencode/test/tool/code-mode-integration.test.ts +++ b/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..(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") }) diff --git a/packages/opencode/test/tool/code-mode.test.ts b/packages/opencode/test/tool/code-mode.test.ts index 027c78d6b2..a220f14530 100644 --- a/packages/opencode/test/tool/code-mode.test.ts +++ b/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") 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: "" })`.', ) diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 0e4b05f3e2..f3ccd5997c 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/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