From 4b92b56b2d271cb484dbc4460a48cd67bbcffd67 Mon Sep 17 00:00:00 2001 From: Kevin O'Connell Date: Thu, 17 Sep 2026 15:10:55 -0700 Subject: [PATCH 1/2] fix(codemode): let the model recover from a wrong tool name in one retry An unknown tool now names the closest real tool with its signature, and the host can claim the name as one of its own tools, so opencode tells the model that `tools.bedrock.find_registry_items` is the agent tool `find-registry-items` and must be called directly. A call with no arguments sends `{}` instead of failing. A parse error says where it is. A caught tool failure stringifies to its message instead of "[object Object]", which was silently discarding the error for the common `catch (e) { return String(e) }` idiom. Co-Authored-By: Claude Fable 5.1 --- packages/codemode/src/codemode.ts | 2 + packages/codemode/src/interpreter/runtime.ts | 24 ++++- packages/codemode/src/stdlib/value.ts | 8 ++ packages/codemode/src/tool-runtime.ts | 102 ++++++++++++------ .../test/recoverable-mistakes.test.ts | 97 +++++++++++++++++ packages/opencode/src/session/tools.ts | 8 +- packages/opencode/src/tool/code-mode.ts | 14 +++ packages/opencode/test/tool/code-mode.test.ts | 17 +++ 8 files changed, 237 insertions(+), 35 deletions(-) create mode 100644 packages/codemode/test/recoverable-mistakes.test.ts diff --git a/packages/codemode/src/codemode.ts b/packages/codemode/src/codemode.ts index 842209dac57d..059786f683da 100644 --- a/packages/codemode/src/codemode.ts +++ b/packages/codemode/src/codemode.ts @@ -49,6 +49,8 @@ export type ExecuteOptions = {}> = { onToolCallStart?: (call: ToolRuntime.ToolCallStarted) => Effect.Effect> /** Observes each admitted tool call as it settles, with outcome and duration. */ onToolCallEnd?: (call: ToolRuntime.ToolCallEnded) => Effect.Effect> + /** Explains an unknown tool path the host recognizes from outside Code Mode; replaces the default suggestions. */ + unknownToolHint?: (path: ReadonlyArray) => string | undefined } /** A JSON value that can cross the confined interpreter boundary. */ diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 093f57776510..9a3bae440030 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -112,8 +112,21 @@ import { SandboxURLSearchParams, } from "../values.js" +const PROGRAM_PREFIX = "async function __codemode__() {\n" + +// A long one-line script gives no clue where it broke, so the retry re-emits it blind. +// The offset is shifted back by the wrapper line the program is parsed inside. +const formatParsePosition = (code: string, start: number | undefined): string => { + if (start === undefined) return "" + const offset = Math.min(Math.max(start - PROGRAM_PREFIX.length, 0), code.length) + const lineStart = code.lastIndexOf("\n", offset - 1) + 1 + const line = code.slice(0, lineStart).split("\n").length + const excerpt = code.slice(Math.max(lineStart, offset - 40), offset + 20).replace(/\s+/g, " ") + return ` (line ${line}, col ${offset - lineStart + 1}) near: ${excerpt}` +} + const parseProgram = (code: string): ProgramNode => { - const transpiled = transpileModule(`async function __codemode__() {\n${code}\n}`, { + const transpiled = transpileModule(`${PROGRAM_PREFIX}${code}\n}`, { reportDiagnostics: true, compilerOptions: { target: ScriptTarget.ESNext, @@ -124,7 +137,7 @@ const parseProgram = (code: string): ProgramNode => { if (diagnostic) { throw new InterpreterRuntimeError( - `Failed to parse TypeScript: ${flattenDiagnosticMessageText(diagnostic.messageText, "\n")}`, + `Failed to parse TypeScript: ${flattenDiagnosticMessageText(diagnostic.messageText, "\n")}${formatParsePosition(code, diagnostic.start)}`, undefined, "ParseError", ) @@ -2913,7 +2926,11 @@ class Interpreter { const raw = yield* self.evaluateExpression(asNode(expressions[index], "expressions")) // The preserving checkpoint keeps sandbox values intact, so coerceToString renders // them directly (ISO date, /regex/ literal form) instead of a JSON-serialized husk. - output += coerceToString(boundedData(raw, "Template interpolation")) + // An error value is read before the bounded copy, which would drop its brand. + output += + errorBrandName(raw) === undefined + ? coerceToString(boundedData(raw, "Template interpolation")) + : coerceToString(raw) } } @@ -3345,6 +3362,7 @@ export const executeWithLimits = >( const hooks = { ...(options.onToolCallStart === undefined ? {} : { onToolCallStart: options.onToolCallStart }), ...(options.onToolCallEnd === undefined ? {} : { onToolCallEnd: options.onToolCallEnd }), + ...(options.unknownToolHint === undefined ? {} : { unknownToolHint: options.unknownToolHint }), } const tools = ToolRuntime.make( (options.tools ?? {}) as HostTools>, diff --git a/packages/codemode/src/stdlib/value.ts b/packages/codemode/src/stdlib/value.ts index 17ca8b1c486f..6b343b396a2a 100644 --- a/packages/codemode/src/stdlib/value.ts +++ b/packages/codemode/src/stdlib/value.ts @@ -30,6 +30,12 @@ export const boundedData = (value: unknown, label: string): unknown => copyIn(va export const coerceToString = (value: unknown): string => { if (value === null) return "null" if (value === undefined) return "undefined" + // `catch (e) { return String(e) }` is the common idiom; "[object Object]" would discard the tool's message. + const errorName = errorBrandName(value) + if (errorName !== undefined) { + const message = (value as { message?: unknown }).message + return typeof message === "string" && message !== "" ? `${errorName}: ${message}` : errorName + } if (value instanceof SandboxDate) return Number.isFinite(value.time) ? new Date(value.time).toISOString() : "Invalid Date" if (value instanceof SandboxRegExp) return `/${value.regex.source}/${value.regex.flags}` @@ -60,6 +66,8 @@ export const invokeCoercion = (ref: CoercionFunction, args: Array, node if (ref.name === "parseInt") return parseInt(coerceToString(raw)) return parseFloat(coerceToString(raw)) } + // The bounded copy below drops an error value's brand, and with it the message. + if (ref.name === "String" && errorBrandName(raw) !== undefined) return coerceToString(raw) const value = boundedData(args[0], `${ref.name} input`) if (ref.name === "Number") return coerceToNumber(value) if (ref.name === "Boolean") return Boolean(value) diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index f4ccc61d4c49..5882361be8fa 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -71,6 +71,11 @@ export type ToolCallEnded = { export type ToolCallHooks = { readonly onToolCallStart?: ((call: ToolCallStarted) => Effect.Effect) | undefined readonly onToolCallEnd?: ((call: ToolCallEnded) => Effect.Effect) | undefined + /** + * Explains an unknown tool path the host recognizes from outside Code Mode, such as a + * tool the agent must call directly. Code Mode itself stays unaware of host tools. + */ + readonly unknownToolHint?: ((path: ReadonlyArray) => string | undefined) | undefined } /** Model-visible description of one schema-backed tool. */ @@ -383,6 +388,37 @@ const termForms = (term: string): Array => { return forms } +/** + * Additive field-weighted scoring, summed across terms: exact path or path segment + * (20) > path substring (8) > description substring (4) > any searchable text, + * including input parameter names and descriptions (2). Best first; entries that match + * no term are dropped unless the query has no terms. + */ +const rankTools = (entries: ReadonlyArray, query: string): ReadonlyArray => { + const terms = tokenize(query).map(termForms) + return entries + .map((entry) => { + const path = entry.description.path.toLowerCase() + const description = entry.description.description.toLowerCase() + const score = terms.reduce( + (total, forms) => + total + + (forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) + + (forms.some((form) => path.includes(form)) ? 8 : 0) + + (forms.some((form) => description.includes(form)) ? 4 : 0) + + (forms.some((form) => entry.searchText.includes(form)) ? 2 : 0), + 0, + ) + return { entry, score } + }) + .filter(({ score }) => terms.length === 0 || score > 0) + .sort( + (left, right) => + right.score - left.score || left.entry.description.path.localeCompare(right.entry.description.path), + ) + .map(({ entry }) => entry) +} + const makeSearchTool = (searchIndex: ReadonlyArray): Definition => ({ _tag: "CodeModeTool", description: "Search available Code Mode tools", @@ -407,34 +443,7 @@ const makeSearchTool = (searchIndex: ReadonlyArray): Definition => : scoped.find( (entry) => entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed, ) - const terms = tokenize(query).map(termForms) - // Additive field-weighted scoring, summed across terms: exact path or path segment - // (20) > path substring (8) > description substring (4) > any searchable text, - // including input parameter names and descriptions (2). - const ranked = - exact !== undefined - ? [exact] - : scoped - .map((entry) => { - const path = entry.description.path.toLowerCase() - const description = entry.description.description.toLowerCase() - const score = terms.reduce( - (total, forms) => - total + - (forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) + - (forms.some((form) => path.includes(form)) ? 8 : 0) + - (forms.some((form) => description.includes(form)) ? 4 : 0) + - (forms.some((form) => entry.searchText.includes(form)) ? 2 : 0), - 0, - ) - return { entry, score } - }) - .filter(({ score }) => terms.length === 0 || score > 0) - .sort( - (left, right) => - right.score - left.score || left.entry.description.path.localeCompare(right.entry.description.path), - ) - .map(({ entry }) => entry) + const ranked = exact !== undefined ? [exact] : rankTools(scoped, query) const items = ranked.slice(offset, offset + (request.limit ?? defaultSearchLimit)).map(({ description }) => ({ ...description, path: toolExpression(description.path), @@ -745,6 +754,36 @@ export const make = ( catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`), }) + // A wrong name costs the model a search and a retry, so the failure names the closest + // real tool with its full signature. The host speaks first: it may know the name as one + // of its own tools, which no search inside Code Mode would ever find. + const explainUnknownTool = (path: ReadonlyArray): ReadonlyArray => { + const hostHint = hooks?.unknownToolHint?.(path) + if (hostHint !== undefined) return [hostHint] + const [namespace = ""] = path + const name = path.at(-1) ?? "" + const inNamespace = searchIndex.filter((entry) => entry.namespace === namespace) + // Models often repeat the namespace in the name (`bedrock.bedrock_send`). + const query = name.startsWith(`${namespace}_`) ? name.slice(namespace.length + 1) : name + const [closest, ...others] = rankTools(inNamespace.length > 0 ? inNamespace : searchIndex, query).slice(0, 3) + if (closest === undefined) return ["Use tools.$codemode.search({ query }) to find available described tools."] + return [ + `Did you mean: ${closest.description.signature}`, + ...(others.length > 0 + ? [`Other close matches: ${others.map((entry) => toolExpression(entry.description.path)).join(", ")}`] + : []), + ] + } + + const resolveOrExplain = (path: ReadonlyArray): HostTool | Definition => { + try { + return resolve(callableTools, path) + } catch (error) { + if (!(error instanceof ToolRuntimeError) || error.kind !== "UnknownTool") throw error + throw new ToolRuntimeError("UnknownTool", error.message, explainUnknownTool(path)) + } + } + const recordCall = (call: ToolCall): void => { if (maxToolCalls !== undefined && calls.length >= maxToolCalls) { throw new ToolRuntimeError("ToolCallLimitExceeded", `Execution exceeded its tool-call limit of ${maxToolCalls}.`) @@ -766,13 +805,14 @@ export const make = ( recordCall(call) return calls.length - 1 }).pipe(Effect.tap((index) => hooks?.onToolCallStart?.({ index, name, input }) ?? Effect.void)) - const tool = resolve(callableTools, path) + const tool = resolveOrExplain(path) let describedInput: unknown if (isDefinition(tool)) { - if (externalArgs.length !== 1) + if (externalArgs.length > 1) throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`) describedInput = yield* Effect.try({ - try: () => decodeToolInput(tool, externalArgs[0]), + // A call with no arguments means "no options"; the tool's own schema still rejects a missing required field. + try: () => decodeToolInput(tool, externalArgs[0] ?? {}), catch: (cause) => new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`), }) diff --git a/packages/codemode/test/recoverable-mistakes.test.ts b/packages/codemode/test/recoverable-mistakes.test.ts new file mode 100644 index 000000000000..8ef2ab84da40 --- /dev/null +++ b/packages/codemode/test/recoverable-mistakes.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Schema } from "effect" +import { CodeMode, Tool, toolError } from "../src/index.js" + +const text = (description: string, value: string) => + Tool.make({ + description, + input: Schema.Struct({ query: Schema.optionalKey(Schema.String) }), + output: Schema.String, + run: () => Effect.succeed(value), + }) + +const tools = { + bedrock: { + registry_find_items: text("Find registry items", "found"), + generate_image: text("Generate an image", "job"), + list_sites: text("List sites", "sites"), + reject: Tool.make({ + description: "Reject a record", + input: Schema.Struct({}), + output: Schema.String, + run: () => Effect.fail(toolError("Field contact_phone must be a valid phone number")), + }), + }, +} + +const execute = (code: string, options: Omit, "tools"> = {}) => + Effect.runPromise(CodeMode.make({ tools, ...options }).execute(code)) + +// Each of these is a mistake models make daily. The reply has to carry the fix, because every +// extra round trip to discover it costs a full model turn. +describe("CodeMode recoverable mistakes", () => { + test("an unknown tool names the closest real tool with its signature", async () => { + const result = await execute(`return await tools.bedrock.find_registry_items({ query: "cro" })`) + + expect(result.ok ? undefined : result.error.kind).toBe("UnknownTool") + expect(result.ok ? undefined : result.error.suggestions?.[0]).toStartWith( + "Did you mean: tools.bedrock.registry_find_items(", + ) + }) + + test("a namespace repeated in the name still finds the tool", async () => { + const result = await execute(`return await tools.bedrock.bedrock_generate_image({ query: "hero" })`) + + expect(result.ok ? undefined : result.error.suggestions?.[0]).toStartWith( + "Did you mean: tools.bedrock.generate_image(", + ) + }) + + test("a name nothing resembles still points at search", async () => { + const result = await execute(`return await tools.bedrock.zzz({})`) + + expect(result.ok ? undefined : result.error.suggestions).toStrictEqual([ + "Use tools.$codemode.search({ query }) to find available described tools.", + ]) + }) + + test("the host can claim an unknown name as one of its own tools", async () => { + const hint = "'set-project-context' is one of your regular tools. Call it directly." + const result = await execute(`return await tools.bedrock.set_project_context({})`, { + unknownToolHint: (path) => (path.at(-1) === "set_project_context" ? hint : undefined), + }) + + expect(result.ok ? undefined : result.error.suggestions).toStrictEqual([hint]) + }) + + test("a call with no arguments sends an empty input object", async () => { + const result = await execute(`return await tools.bedrock.list_sites()`) + + expect(result.ok ? result.value : result.error).toBe("sites") + }) + + test("a second argument is still rejected", async () => { + const result = await execute(`return await tools.bedrock.list_sites({}, {})`) + + expect(result.ok ? undefined : result.error.kind).toBe("InvalidToolInput") + }) + + test("a parse error says where it is", async () => { + const result = await execute(`const sites = 1\nreturn await tools.bedrock.list_sites({)`) + + expect(result.ok ? undefined : result.error.kind).toBe("ParseError") + expect(result.ok ? undefined : result.error.message).toMatch(/\(line 2, col \d+\) near: .*list_sites/) + }) + + test("a caught tool failure stringifies to its message", async () => { + const result = await execute( + `try { await tools.bedrock.reject({}) } catch (e) { return [String(e), \`failed: \${e}\`, "" + e] }`, + ) + + expect(result.ok ? result.value : result.error).toStrictEqual([ + "Error: Field contact_phone must be a valid phone number", + "failed: Error: Field contact_phone must be a valid phone number", + "Error: Field contact_phone must be a valid phone number", + ]) + }) +}) diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 815a87818f7e..2c9ad3da6679 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -62,7 +62,13 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { abort: options.abortSignal!, messageID: input.processor.message.id, callID: options.toolCallId, - extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps: input.promptOps }, + extra: { + model: input.model, + bypassAgentCheck: input.bypassAgentCheck, + promptOps: input.promptOps, + // Lets code mode tell the model that a name it tried inside a script is one of these instead. + toolIDs: Object.keys(tools), + }, agent: input.agent.name, messages: input.messages, metadata: (val) => diff --git a/packages/opencode/src/tool/code-mode.ts b/packages/opencode/src/tool/code-mode.ts index 931618c568d5..934578e8146b 100644 --- a/packages/opencode/src/tool/code-mode.ts +++ b/packages/opencode/src/tool/code-mode.ts @@ -115,6 +115,19 @@ function projectMcpResult(result: CallToolResult, collect: (attachment: Attachme return null } +// The model knows `find-registry-items` as an agent tool and reaches for it inside a script as +// `tools..find_registry_items`; no search of the MCP catalog can correct that. +function agentToolHint(path: ReadonlyArray, toolIDs: unknown): string | undefined { + if (!Array.isArray(toolIDs)) return + const fold = (name: string) => name.toLowerCase().replaceAll(/[-_]/g, "") + const wanted = fold(path.at(-1) ?? "") + const match = toolIDs.find( + (id): id is string => typeof id === "string" && id !== CODE_MODE_TOOL && fold(id) === wanted, + ) + if (match === undefined) return + return `'${match}' is one of your regular tools, not a Code Mode tool. Call it directly, outside ${CODE_MODE_TOOL}.` +} + type Run = (input: unknown) => Effect.Effect function toolTree(catalog: readonly CatalogEntry[], run: (entry: CatalogEntry) => Run) { @@ -248,6 +261,7 @@ export const CodeModeTool = Tool.define( const runtime = CodeMode.make({ tools: toolTree(catalog, callTool), + unknownToolHint: (path) => agentToolHint(path, ctx.extra?.toolIDs), onToolCallStart: ({ index, name, input }) => Effect.suspend(() => { const shown = (() => { diff --git a/packages/opencode/test/tool/code-mode.test.ts b/packages/opencode/test/tool/code-mode.test.ts index 3011a0a0fdfa..f398a90f4b98 100644 --- a/packages/opencode/test/tool/code-mode.test.ts +++ b/packages/opencode/test/tool/code-mode.test.ts @@ -348,6 +348,23 @@ describe("code mode execute", () => { expect(error.message).toContain("Unknown tool 'known.missing'") }) + test("an unknown tool names the closest MCP tool so the retry needs no search", async () => { + const tool = await build({ bedrock_registry_find_items: mcpTool("registry_find_items", () => "ok") }) + const error = await failure(tool.execute({ code: "return await tools.bedrock.find_registry_items({})" }, ctx)) + expect(error.message).toContain("Did you mean: tools.bedrock.registry_find_items(") + }) + + test("a regular agent tool called inside a script is sent back to a direct call", async () => { + const tool = await build({ bedrock_list_sites: mcpTool("list_sites", () => "ok") }) + const error = await failure( + tool.execute( + { code: "return await tools.bedrock.set_project_context({})" }, + { ...ctx, extra: { toolIDs: ["read", "set-project-context", CODE_MODE_TOOL] } }, + ), + ) + expect(error.message).toContain("'set-project-context' is one of your regular tools") + }) + test("propagates an MCP tool error into the program as a catchable failure", async () => { const tool = await build({ bad_tool: mcpTool("tool", () => ({ isError: true, content: [{ type: "text", text: "server exploded" }] })), From 144ce869da1d7deefe198e3a2b858fc8f172d020 Mon Sep 17 00:00:00 2001 From: Kevin O'Connell Date: Thu, 17 Sep 2026 16:53:12 -0700 Subject: [PATCH 2/2] refactor(codemode): keep error values as sandbox leaves and explain unknown tools inside resolve The error brand now lives with the other sandbox value types, and the intra-sandbox copy keeps a branded error as a leaf the way it keeps a Date or a Map, so the two per-site bypasses in String() and template literals go away. resolve takes the explain function directly instead of being wrapped in a try/catch that re-threw the same error with new suggestions. The parse position drops clamps for offsets that cannot occur. Co-Authored-By: Claude Fable 5.1 --- packages/codemode/src/interpreter/runtime.ts | 15 ++---- packages/codemode/src/stdlib/value.ts | 24 ++------- packages/codemode/src/tool-runtime.ts | 55 +++++++------------- packages/codemode/src/values.ts | 13 +++++ packages/opencode/src/tool/code-mode.ts | 7 +-- 5 files changed, 43 insertions(+), 71 deletions(-) diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 9a3bae440030..9cb93171507f 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -95,13 +95,13 @@ import { coerceToNumber, coerceToString, compoundOperators, - createErrorValue, - errorBrandName, errorConstructors, invokeCoercion, valueConstructors, } from "../stdlib/value.js" import { + createErrorValue, + errorBrandName, isSandboxValue, SandboxDate, SandboxMap, @@ -114,11 +114,10 @@ import { const PROGRAM_PREFIX = "async function __codemode__() {\n" -// A long one-line script gives no clue where it broke, so the retry re-emits it blind. -// The offset is shifted back by the wrapper line the program is parsed inside. +// Without a position the retry re-emits a long script blind; the offset counts from inside the wrapper. const formatParsePosition = (code: string, start: number | undefined): string => { if (start === undefined) return "" - const offset = Math.min(Math.max(start - PROGRAM_PREFIX.length, 0), code.length) + const offset = start - PROGRAM_PREFIX.length const lineStart = code.lastIndexOf("\n", offset - 1) + 1 const line = code.slice(0, lineStart).split("\n").length const excerpt = code.slice(Math.max(lineStart, offset - 40), offset + 20).replace(/\s+/g, " ") @@ -2926,11 +2925,7 @@ class Interpreter { const raw = yield* self.evaluateExpression(asNode(expressions[index], "expressions")) // The preserving checkpoint keeps sandbox values intact, so coerceToString renders // them directly (ISO date, /regex/ literal form) instead of a JSON-serialized husk. - // An error value is read before the bounded copy, which would drop its brand. - output += - errorBrandName(raw) === undefined - ? coerceToString(boundedData(raw, "Template interpolation")) - : coerceToString(raw) + output += coerceToString(boundedData(raw, "Template interpolation")) } } diff --git a/packages/codemode/src/stdlib/value.ts b/packages/codemode/src/stdlib/value.ts index 6b343b396a2a..0ed51248e174 100644 --- a/packages/codemode/src/stdlib/value.ts +++ b/packages/codemode/src/stdlib/value.ts @@ -12,19 +12,6 @@ export const valueConstructors = new Set(["Date", "RegExp", "Map", "Set", "URL", export const compoundOperators = new Set(["+=", "-=", "*=", "/=", "%=", "**=", "&=", "|=", "^=", "<<=", ">>=", ">>>="]) -const ErrorBrand: unique symbol = Symbol("codemode.error") - -export const createErrorValue = (name: string, message: string): SafeObject => { - const value = Object.assign(Object.create(null) as SafeObject, { name, message }) - Object.defineProperty(value, ErrorBrand, { value: name }) - return value -} - -export const errorBrandName = (value: unknown): string | undefined => - value !== null && typeof value === "object" - ? ((value as Record)[ErrorBrand] as string | undefined) - : undefined - export const boundedData = (value: unknown, label: string): unknown => copyIn(value, label, true) export const coerceToString = (value: unknown): string => { @@ -66,8 +53,6 @@ export const invokeCoercion = (ref: CoercionFunction, args: Array, node if (ref.name === "parseInt") return parseInt(coerceToString(raw)) return parseFloat(coerceToString(raw)) } - // The bounded copy below drops an error value's brand, and with it the message. - if (ref.name === "String" && errorBrandName(raw) !== undefined) return coerceToString(raw) const value = boundedData(args[0], `${ref.name} input`) if (ref.name === "Number") return coerceToNumber(value) if (ref.name === "Boolean") return Boolean(value) @@ -81,13 +66,10 @@ export const invokeCoercion = (ref: CoercionFunction, args: Array, node if (ref.name === "parseFloat") return parseFloat(coerceToString(value)) return coerceToString(value) } +import { type AstNode, CoercionFunction, InterpreterRuntimeError } from "../interpreter/model.js" +import { copyIn } from "../tool-runtime.js" import { - type AstNode, - CoercionFunction, - InterpreterRuntimeError, -} from "../interpreter/model.js" -import { copyIn, type SafeObject } from "../tool-runtime.js" -import { + errorBrandName, isSandboxValue, SandboxDate, SandboxMap, diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index 5882361be8fa..fcb1f7a97373 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -10,6 +10,7 @@ import { } from "./tool-schema.js" import { isDefinition as isToolDefinition, type Definition } from "./tool.js" import { + errorBrandName, SandboxDate, SandboxMap, SandboxPromise, @@ -71,10 +72,7 @@ export type ToolCallEnded = { export type ToolCallHooks = { readonly onToolCallStart?: ((call: ToolCallStarted) => Effect.Effect) | undefined readonly onToolCallEnd?: ((call: ToolCallEnded) => Effect.Effect) | undefined - /** - * Explains an unknown tool path the host recognizes from outside Code Mode, such as a - * tool the agent must call directly. Code Mode itself stays unaware of host tools. - */ + /** Explains an unknown tool path the host recognizes from outside Code Mode; replaces the default suggestions. */ readonly unknownToolHint?: ((path: ReadonlyArray) => string | undefined) | undefined } @@ -216,14 +214,15 @@ const copyBounded = ( if (preserveSandboxValues) { // Intra-sandbox checkpoints keep sandbox value instances alive as leaves; their contents // are never walked here (Map/Set members are validated where mutation happens, and the - // real boundary still serializes them below). + // real boundary still serializes them below). A caught error keeps its brand the same way. if ( value instanceof SandboxDate || value instanceof SandboxRegExp || value instanceof SandboxMap || value instanceof SandboxSet || value instanceof SandboxURL || - value instanceof SandboxURLSearchParams + value instanceof SandboxURLSearchParams || + errorBrandName(value) !== undefined ) { return value } @@ -388,12 +387,8 @@ const termForms = (term: string): Array => { return forms } -/** - * Additive field-weighted scoring, summed across terms: exact path or path segment - * (20) > path substring (8) > description substring (4) > any searchable text, - * including input parameter names and descriptions (2). Best first; entries that match - * no term are dropped unless the query has no terms. - */ +// Field-weighted score summed across terms: exact path or segment 20, path substring 8, description 4, +// any searchable text (input names and descriptions) 2. Entries matching no term are dropped unless the query is empty. const rankTools = (entries: ReadonlyArray, query: string): ReadonlyArray => { const terms = tokenize(query).map(termForms) return entries @@ -682,7 +677,11 @@ const namespaceKeys = (tools: HostTools, path: ReadonlyArray): Rea return Object.keys(value) } -const resolve = (tools: HostTools, path: ReadonlyArray): HostTool | Definition => { +const resolve = ( + tools: HostTools, + path: ReadonlyArray, + explain: (path: ReadonlyArray) => ReadonlyArray, +): HostTool | Definition => { let value: HostTool | Definition | HostTools = tools for (const segment of path) { @@ -692,9 +691,7 @@ const resolve = (tools: HostTools, path: ReadonlyArray): HostTool< isDefinition(value) || !Object.hasOwn(value, segment) ) { - throw new ToolRuntimeError("UnknownTool", `Unknown tool '${path.join(".")}'.`, [ - "Use tools.$codemode.search({ query }) to find available described tools.", - ]) + throw new ToolRuntimeError("UnknownTool", `Unknown tool '${path.join(".")}'.`, explain(path)) } value = value[segment] as HostTool | Definition | HostTools } @@ -754,9 +751,8 @@ export const make = ( catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`), }) - // A wrong name costs the model a search and a retry, so the failure names the closest - // real tool with its full signature. The host speaks first: it may know the name as one - // of its own tools, which no search inside Code Mode would ever find. + // A wrong name otherwise costs the model a search and a retry. The host speaks first: it may + // know the name as one of its own tools, which no search inside Code Mode would find. const explainUnknownTool = (path: ReadonlyArray): ReadonlyArray => { const hostHint = hooks?.unknownToolHint?.(path) if (hostHint !== undefined) return [hostHint] @@ -767,21 +763,10 @@ export const make = ( const query = name.startsWith(`${namespace}_`) ? name.slice(namespace.length + 1) : name const [closest, ...others] = rankTools(inNamespace.length > 0 ? inNamespace : searchIndex, query).slice(0, 3) if (closest === undefined) return ["Use tools.$codemode.search({ query }) to find available described tools."] - return [ - `Did you mean: ${closest.description.signature}`, - ...(others.length > 0 - ? [`Other close matches: ${others.map((entry) => toolExpression(entry.description.path)).join(", ")}`] - : []), - ] - } - - const resolveOrExplain = (path: ReadonlyArray): HostTool | Definition => { - try { - return resolve(callableTools, path) - } catch (error) { - if (!(error instanceof ToolRuntimeError) || error.kind !== "UnknownTool") throw error - throw new ToolRuntimeError("UnknownTool", error.message, explainUnknownTool(path)) - } + const hints = [`Did you mean: ${closest.description.signature}`] + if (others.length > 0) + hints.push(`Other close matches: ${others.map((entry) => toolExpression(entry.description.path)).join(", ")}`) + return hints } const recordCall = (call: ToolCall): void => { @@ -805,7 +790,7 @@ export const make = ( recordCall(call) return calls.length - 1 }).pipe(Effect.tap((index) => hooks?.onToolCallStart?.({ index, name, input }) ?? Effect.void)) - const tool = resolveOrExplain(path) + const tool = resolve(callableTools, path, explainUnknownTool) let describedInput: unknown if (isDefinition(tool)) { if (externalArgs.length > 1) diff --git a/packages/codemode/src/values.ts b/packages/codemode/src/values.ts index 4ca305d815eb..789a61bbcd0a 100644 --- a/packages/codemode/src/values.ts +++ b/packages/codemode/src/values.ts @@ -47,3 +47,16 @@ export const isSandboxValue = ( value instanceof SandboxSet || value instanceof SandboxURL || value instanceof SandboxURLSearchParams + +const ErrorBrand: unique symbol = Symbol("codemode.error") + +export const createErrorValue = (name: string, message: string): Record => { + const value = Object.assign(Object.create(null) as Record, { name, message }) + Object.defineProperty(value, ErrorBrand, { value: name }) + return value +} + +export const errorBrandName = (value: unknown): string | undefined => + value !== null && typeof value === "object" + ? ((value as Record)[ErrorBrand] as string | undefined) + : undefined diff --git a/packages/opencode/src/tool/code-mode.ts b/packages/opencode/src/tool/code-mode.ts index 934578e8146b..20c8b54085b8 100644 --- a/packages/opencode/src/tool/code-mode.ts +++ b/packages/opencode/src/tool/code-mode.ts @@ -115,15 +115,12 @@ function projectMcpResult(result: CallToolResult, collect: (attachment: Attachme return null } -// The model knows `find-registry-items` as an agent tool and reaches for it inside a script as +// The model reaches for an agent tool such as `find-registry-items` inside a script as // `tools..find_registry_items`; no search of the MCP catalog can correct that. function agentToolHint(path: ReadonlyArray, toolIDs: unknown): string | undefined { - if (!Array.isArray(toolIDs)) return const fold = (name: string) => name.toLowerCase().replaceAll(/[-_]/g, "") const wanted = fold(path.at(-1) ?? "") - const match = toolIDs.find( - (id): id is string => typeof id === "string" && id !== CODE_MODE_TOOL && fold(id) === wanted, - ) + const match = Array.isArray(toolIDs) ? toolIDs.find((id) => id !== CODE_MODE_TOOL && fold(id) === wanted) : undefined if (match === undefined) return return `'${match}' is one of your regular tools, not a Code Mode tool. Call it directly, outside ${CODE_MODE_TOOL}.` }