From 01abe0a33dd89ffae53e03e182ca6971b9d9f43e Mon Sep 17 00:00:00 2001 From: mushuiyu886 Date: Tue, 23 Jun 2026 14:20:09 +0800 Subject: [PATCH] fix(agents): suggest recovery for unknown tool ids (#93374) Merged via squash. Prepared head SHA: bee84e4eb8a238f423ea3d2ae0207d53ed827217 Co-authored-by: mushuiyu886 <266724580+mushuiyu886@users.noreply.github.com> Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com> Reviewed-by: @vincentkoc --- src/agents/code-mode.test.ts | 71 +++++++++++++++++++- src/agents/code-mode.ts | 77 ++++++++++++++-------- src/agents/code-mode.worker.ts | 40 +++++++++-- src/agents/tool-search.test.ts | 117 ++++++++++++++++++++++++++++++++- src/agents/tool-search.ts | 98 ++++++++++++++++++++++++--- 5 files changed, 360 insertions(+), 43 deletions(-) diff --git a/src/agents/code-mode.test.ts b/src/agents/code-mode.test.ts index 2b6000deec7..bc484be3198 100644 --- a/src/agents/code-mode.test.ts +++ b/src/agents/code-mode.test.ts @@ -779,6 +779,71 @@ describe("Code Mode", () => { expect(ticket.execute).toHaveBeenCalledTimes(1); }); + it("uses tools recovery guidance for guessed tool ids", async () => { + const { config, catalogRef, tools: codeModeTools } = createCodeModeHarness(); + const writeTool = pluginTool("write", "Write a file to the workspace"); + applyCodeModeCatalog({ + tools: [...codeModeTools, writeTool], + config, + sessionId: "session-code-mode", + sessionKey: "agent:main:main", + runId: "run-code-mode", + catalogRef, + }); + + const details = await runUntilCompleted({ + execTool: codeModeTools[0], + waitTool: codeModeTools[1], + code: ` + try { + await tools.call("file_write", { + path: "memory/2026-05-22.md", + content: "remember this", + }); + return "unexpected success"; + } catch (error) { + return error.message; + } + `, + }); + + expect(details.status).toBe("completed"); + expect(details.value).toBe( + "Unknown tool id: file_write. Did you mean: write? Use tools.search to find a tool, tools.describe to inspect it, then tools.call with the exact id or name.", + ); + expect(writeTool.execute).not.toHaveBeenCalled(); + }); + + it("uses tools recovery guidance when no generic Code Mode suggestion matches", async () => { + const { config, catalogRef, tools: codeModeTools } = createCodeModeHarness(); + applyCodeModeCatalog({ + tools: codeModeTools, + config, + sessionId: "session-code-mode", + sessionKey: "agent:main:main", + runId: "run-code-mode", + catalogRef, + }); + + const details = await runUntilCompleted({ + execTool: codeModeTools[0], + waitTool: codeModeTools[1], + code: ` + try { + await tools.call("missing_tool", {}); + return "unexpected success"; + } catch (error) { + return error.message; + } + `, + }); + + expect(details.status).toBe("completed"); + expect(details.value).toBe( + "Unknown tool id: missing_tool. Use tools.search to find a tool, tools.describe to inspect it, then tools.call with the exact id or name.", + ); + }); + it("exposes MCP tools only through the MCP namespace", async () => { const { config, catalogRef, tools: codeModeTools } = createCodeModeHarness(); const githubCreate = mcpTool({ @@ -882,8 +947,10 @@ describe("Code Mode", () => { }, searchHits: [], allHasMcp: false, - directDescribe: "Unknown tool id: github__create_issue", - directCall: "Unknown tool id: github__create_issue", + directDescribe: + "Unknown tool id: github__create_issue. Use tools.search to find a tool, tools.describe to inspect it, then tools.call with the exact id or name.", + directCall: + "Unknown tool id: github__create_issue. Use tools.search to find a tool, tools.describe to inspect it, then tools.call with the exact id or name.", hasMcp: true, apiSchemaTitle: "object", apiHeader: expect.stringContaining("function createIssue("), diff --git a/src/agents/code-mode.ts b/src/agents/code-mode.ts index 0702d4f97cb..de3ed6eb772 100644 --- a/src/agents/code-mode.ts +++ b/src/agents/code-mode.ts @@ -305,13 +305,24 @@ class CodeModeLimitError extends ToolInputError { } } +function isRuntimeInterruptedError(error: unknown): boolean { + return errorMessage(error) === "interrupted"; +} + function codeModeFailureCode(error: unknown): CodeModeFailureCode { if (error instanceof CodeModeLimitError) { return error.code; } + if (isRuntimeInterruptedError(error)) { + return "timeout"; + } return error instanceof ToolInputError ? "invalid_input" : "internal_error"; } +function codeModeFailureMessage(error: unknown): string { + return isRuntimeInterruptedError(error) ? "code mode timeout exceeded" : errorMessage(error); +} + function enforceOutputLimit(output: unknown[], config: CodeModeConfig): void { if (jsonByteLength(output) > config.maxOutputBytes) { throw new CodeModeLimitError("output_limit_exceeded", "code mode output limit exceeded"); @@ -505,7 +516,10 @@ async function runBridgeRequest(params: { if (typeof id !== "string") { throw new ToolInputError("describe id must be a string."); } - value = await params.runtime.describe(id, { includeMcp: false }); + value = await params.runtime.describe(id, { + includeMcp: false, + recoverySurface: "tools", + }); break; } case "call": { @@ -513,7 +527,10 @@ async function runBridgeRequest(params: { if (typeof id !== "string") { throw new ToolInputError("call id must be a string."); } - const described = await params.runtime.describe(id, { includeMcp: false }); + const described = await params.runtime.describe(id, { + includeMcp: false, + recoverySurface: "tools", + }); value = await params.runtime.callExactId(described.id, values[1] ?? {}, { parentToolCallId: params.parentToolCallId, signal: params.signal, @@ -606,24 +623,26 @@ function failedCodeModeWorkerResult( }; } -function isQuickJsInterruptedWorkerError(error: unknown): boolean { - return String(error) === "interrupted"; -} - -function normalizeCodeModeWorkerResult(result: CodeModeWorkerResult): CodeModeWorkerResult { +function normalizeCodeModeTimeoutResult< + T extends { status: string; code?: unknown; error?: unknown }, +>(result: T): T { if ( result.status === "failed" && result.code === "timeout" && - isQuickJsInterruptedWorkerError(result.error) + !String(result.error).includes("timeout exceeded") ) { return { ...result, error: "code mode timeout exceeded", - }; + } as T; } return result; } +function normalizeCodeModeWorkerResult(result: CodeModeWorkerResult): CodeModeWorkerResult { + return normalizeCodeModeTimeoutResult(result); +} + async function runCodeModeWorker( workerData: unknown, timeoutMs: number, @@ -855,7 +874,7 @@ async function runExec(params: { } catch (error) { return { status: "failed" as const, - error: errorMessage(error), + error: codeModeFailureMessage(error), code: codeModeFailureCode(error), output: [], telemetry: telemetry(runtime), @@ -889,7 +908,7 @@ async function runExec(params: { } catch (error) { return { status: "failed" as const, - error: errorMessage(error), + error: codeModeFailureMessage(error), code: codeModeFailureCode(error), output: [], telemetry: telemetry(runtime), @@ -1094,7 +1113,7 @@ async function runWait(params: { } catch (error) { return { status: "failed" as const, - error: errorMessage(error), + error: codeModeFailureMessage(error), code: codeModeFailureCode(error), output: state.output, telemetry: telemetry(state.runtime), @@ -1135,14 +1154,16 @@ export function createCodeModeTools(ctx: CodeModeToolContext): AnyAgentTool[] { ) => { const input = readCode(args); return jsonResult( - await runExec({ - toolCallId, - ctx, - code: input.code, - language: input.language, - signal, - onUpdate, - }), + normalizeCodeModeTimeoutResult( + await runExec({ + toolCallId, + ctx, + code: input.code, + language: input.language, + signal, + onUpdate, + }), + ), ); }, } as AnyAgentTool); @@ -1160,13 +1181,15 @@ export function createCodeModeTools(ctx: CodeModeToolContext): AnyAgentTool[] { onUpdate?: AgentToolUpdateCallback, ) => jsonResult( - await runWait({ - toolCallId, - ctx, - runId: readRunId(args), - signal, - onUpdate, - }), + normalizeCodeModeTimeoutResult( + await runWait({ + toolCallId, + ctx, + runId: readRunId(args), + signal, + onUpdate, + }), + ), ), } as AnyAgentTool); return [execTool, waitTool]; diff --git a/src/agents/code-mode.worker.ts b/src/agents/code-mode.worker.ts index 3ad686101dd..9502bd37af0 100644 --- a/src/agents/code-mode.worker.ts +++ b/src/agents/code-mode.worker.ts @@ -6,8 +6,6 @@ import { readFile } from "node:fs/promises"; import { createRequire } from "node:module"; import { parentPort, workerData } from "node:worker_threads"; import { EvalFlags, Intrinsics, JSException, QuickJS, type JSValueHandle } from "quickjs-wasi"; -import { toCodeModeJsonSafe as toJsonSafe } from "./code-mode-json.js"; - const require = createRequire(import.meta.url); const QUICKJS_WASM_PATH = require.resolve("quickjs-wasi/quickjs.wasm"); let quickJsWasmModulePromise: Promise | undefined; @@ -162,6 +160,35 @@ function errorMessage(error: unknown): string { return String(error); } +function toJsonSafe(value: unknown): unknown { + if (value === undefined) { + return null; + } + try { + const serialized = JSON.stringify(value); + return serialized === undefined ? null : (JSON.parse(serialized) as unknown); + } catch { + if (value instanceof Error) { + return { name: value.name, message: value.message }; + } + if (value === null) { + return null; + } + switch (typeof value) { + case "string": + case "number": + case "boolean": + return value; + case "bigint": + case "symbol": + case "function": + return String(value); + default: + return Object.prototype.toString.call(value); + } + } +} + const CONTROLLER_SOURCE = String.raw` (() => { const output = []; @@ -727,10 +754,15 @@ async function main(): Promise { output: [], }; } catch (error) { + const timedOut = isQuickJsInterruptedError(error); return { status: "failed", - error: errorMessage(error), - code: error instanceof CodeModeWorkerFailure ? error.code : "internal_error", + error: timedOut ? "code mode timeout exceeded" : errorMessage(error), + code: timedOut + ? "timeout" + : error instanceof CodeModeWorkerFailure + ? error.code + : "internal_error", output: error instanceof CodeModeWorkerFailureWithOutput ? error.output : [], }; } diff --git a/src/agents/tool-search.test.ts b/src/agents/tool-search.test.ts index 3138b0b5122..dd7d9fb2050 100644 --- a/src/agents/tool-search.test.ts +++ b/src/agents/tool-search.test.ts @@ -1288,6 +1288,119 @@ describe("Tool Search", () => { ).rejects.toThrow(); }); + it("suggests recoverable Tool Search steps for guessed tool ids", async () => { + const callTool = fakeTool(TOOL_CALL_RAW_TOOL_NAME, "call"); + const searchTool = fakeTool(TOOL_SEARCH_RAW_TOOL_NAME, "search"); + const describeTool = fakeTool(TOOL_DESCRIBE_RAW_TOOL_NAME, "describe"); + const writeTool = fakeTool("write", "Write a file to the workspace"); + applyToolSearchCatalog({ + tools: [callTool, searchTool, describeTool, writeTool], + config: { tools: { toolSearch: { mode: "tools" } } } as never, + sessionId: "session-guessed-file-write", + sessionKey: "agent:main:main", + }); + + const runtimeTools = createToolSearchTools({ + sessionId: "session-guessed-file-write", + sessionKey: "agent:main:main", + config: { tools: { toolSearch: { mode: "tools" } } } as never, + }); + const runtimeCallTool = runtimeTools[3]; + + await expect( + runtimeCallTool.execute("call-guessed-file-write", { + id: "file_write", + args: { path: "memory/2026-05-22.md", content: "remember this" }, + }), + ).rejects.toThrow( + "Unknown tool id: file_write. Did you mean: write? Use tool_search to find a tool, tool_describe to inspect it, then tool_call with the exact id or name.", + ); + expect(writeTool.execute).not.toHaveBeenCalled(); + }); + + it("uses exact ids when recovery suggestions have duplicate names", async () => { + const callTool = fakeTool(TOOL_CALL_RAW_TOOL_NAME, "call"); + const searchTool = fakeTool(TOOL_SEARCH_RAW_TOOL_NAME, "search"); + const describeTool = fakeTool(TOOL_DESCRIBE_RAW_TOOL_NAME, "describe"); + const firstWriteTool = pluginTool("write", "Write a file", "first-plugin"); + const secondWriteTool = pluginTool("write", "Write another file", "second-plugin"); + applyToolSearchCatalog({ + tools: [callTool, searchTool, describeTool, firstWriteTool, secondWriteTool], + config: { tools: { toolSearch: { mode: "tools" } } } as never, + sessionId: "session-duplicate-recovery", + sessionKey: "agent:main:main", + }); + + const runtimeTools = createToolSearchTools({ + sessionId: "session-duplicate-recovery", + sessionKey: "agent:main:main", + config: { tools: { toolSearch: { mode: "tools" } } } as never, + }); + + await expect( + runtimeTools[3].execute("call-duplicate-write", { + id: "file_write", + args: {}, + }), + ).rejects.toThrow("Did you mean: openclaw:first-plugin:write, openclaw:second-plugin:write?"); + }); + + it("keeps raw Tool Search recovery guidance when no suggestion matches", async () => { + const callTool = fakeTool(TOOL_CALL_RAW_TOOL_NAME, "call"); + const searchTool = fakeTool(TOOL_SEARCH_RAW_TOOL_NAME, "search"); + const describeTool = fakeTool(TOOL_DESCRIBE_RAW_TOOL_NAME, "describe"); + const writeTool = fakeTool("write", "Write a file to the workspace"); + applyToolSearchCatalog({ + tools: [callTool, searchTool, describeTool, writeTool], + config: { tools: { toolSearch: { mode: "tools" } } } as never, + sessionId: "session-missing-raw-tool", + sessionKey: "agent:main:main", + }); + + const runtimeTools = createToolSearchTools({ + sessionId: "session-missing-raw-tool", + sessionKey: "agent:main:main", + config: { tools: { toolSearch: { mode: "tools" } } } as never, + }); + const runtimeCallTool = runtimeTools[3]; + + await expect( + runtimeCallTool.execute("call-missing-raw-tool", { + id: "missing_tool", + args: {}, + }), + ).rejects.toThrow( + "Unknown tool id: missing_tool. Use tool_search to find a tool, tool_describe to inspect it, then tool_call with the exact id or name.", + ); + expect(writeTool.execute).not.toHaveBeenCalled(); + }); + + it("preserves code-mode bridge recovery guidance for guessed tool ids", async () => { + const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode"); + const writeTool = fakeTool("write", "Write a file to the workspace"); + applyToolSearchCatalog({ + tools: [codeTool, writeTool], + config: { tools: { toolSearch: true } } as never, + sessionId: "session-code-guessed-file-write", + sessionKey: "agent:main:main", + }); + + const [runtimeCodeTool] = createToolSearchTools({ + sessionId: "session-code-guessed-file-write", + sessionKey: "agent:main:main", + config: {}, + }); + + await expect( + runtimeCodeTool.execute("call-code-guessed-file-write", { + code: `return await openclaw.tools.call("file_write", { path: "memory/2026-05-22.md" });`, + }), + ).rejects.toThrow( + "Unknown tool id: file_write. Did you mean: write? Use openclaw.tools.search to find a tool, openclaw.tools.describe to inspect it, then openclaw.tools.call with the exact id or name.", + ); + expect(writeTool.execute).not.toHaveBeenCalled(); + }); + it("preserves code-mode bridge errors from the child process", async () => { const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode"); applyToolSearchCatalog({ @@ -1307,7 +1420,9 @@ describe("Tool Search", () => { runtimeCodeTool.execute("call-missing-tool", { code: `return await openclaw.tools.call("missing_tool", {});`, }), - ).rejects.toThrow("Unknown tool id: missing_tool"); + ).rejects.toThrow( + "Unknown tool id: missing_tool. Use openclaw.tools.search to find a tool, openclaw.tools.describe to inspect it, then openclaw.tools.call with the exact id or name.", + ); }); it("does not expose host-realm bridge result objects to model-authored code", async () => { diff --git a/src/agents/tool-search.ts b/src/agents/tool-search.ts index 801d29b6881..5f327054ae1 100644 --- a/src/agents/tool-search.ts +++ b/src/agents/tool-search.ts @@ -55,6 +55,11 @@ type CatalogTool = AnyAgentTool | ToolDefinition; type CatalogVisibilityOptions = { includeMcp?: boolean; }; +type UnknownToolRecoverySurface = "raw-tools" | "code-mode" | "tools"; +type UnknownToolErrorOptions = { + exactIdOnly?: boolean; + recoverySurface?: UnknownToolRecoverySurface; +}; type ReusableCatalogSnapshot = { entries: ToolSearchCatalogEntry[]; @@ -1539,10 +1544,74 @@ function visibleCatalogEntries( : catalog.entries; } +function tokenizeLookupValue(input: string): Set { + return new Set(normalizeStringEntries(input.toLowerCase().split(/[^a-z0-9]+/u))); +} + +function scoreUnknownToolSuggestion(needle: string, entry: ToolSearchCatalogEntry): number { + const normalizedNeedle = needle.toLowerCase(); + const name = entry.name.toLowerCase(); + const id = entry.id.toLowerCase(); + const label = (entry.label ?? "").toLowerCase(); + const description = entry.description.toLowerCase(); + const needleTokens = tokenizeLookupValue(needle); + const entryTokens = tokenizeLookupValue( + `${entry.name} ${entry.id} ${entry.label ?? ""} ${entry.description}`, + ); + let score = 0; + if ((name && normalizedNeedle.includes(name)) || id.includes(normalizedNeedle)) { + score += 40; + } + if (name && needleTokens.has(name)) { + score += 40; + } + for (const token of needleTokens) { + if (entryTokens.has(token)) { + score += 12; + } + } + if (label.includes(normalizedNeedle) || description.includes(normalizedNeedle)) { + score += 8; + } + return score; +} + +function formatUnknownToolIdError( + needle: string, + entries: readonly ToolSearchCatalogEntry[], + options: UnknownToolErrorOptions = {}, +): string { + const nameCounts = new Map(); + for (const entry of entries) { + nameCounts.set(entry.name, (nameCounts.get(entry.name) ?? 0) + 1); + } + const suggestions = uniqueStrings( + entries + .map((entry) => ({ + value: options.exactIdOnly || (nameCounts.get(entry.name) ?? 0) > 1 ? entry.id : entry.name, + score: scoreUnknownToolSuggestion(needle, entry), + })) + .filter((candidate) => candidate.score > 0) + .toSorted((a, b) => b.score - a.score || a.value.localeCompare(b.value)) + .map((candidate) => candidate.value), + ).slice(0, 3); + const recoveryText = + options.recoverySurface === "code-mode" + ? "Use openclaw.tools.search to find a tool, openclaw.tools.describe to inspect it, then openclaw.tools.call with the exact id or name." + : options.recoverySurface === "tools" + ? "Use tools.search to find a tool, tools.describe to inspect it, then tools.call with the exact id or name." + : "Use tool_search to find a tool, tool_describe to inspect it, then tool_call with the exact id or name."; + if (suggestions.length === 0) { + return `Unknown tool id: ${needle}. ${recoveryText}`; + } + return `Unknown tool id: ${needle}. Did you mean: ${suggestions.join(", ")}? ${recoveryText}`; +} + function findEntry( catalog: ToolSearchCatalogSession, id: string, options?: CatalogVisibilityOptions, + errorOptions?: UnknownToolErrorOptions, ): ToolSearchCatalogEntry { const needle = id.trim(); const entries = visibleCatalogEntries(catalog, options); @@ -1556,16 +1625,22 @@ function findEntry( } const namedEntry = namedEntries[0]; if (!namedEntry) { - throw new ToolInputError(`Unknown tool id: ${needle}`); + throw new ToolInputError(formatUnknownToolIdError(needle, entries, errorOptions)); } return namedEntry; } -function findEntryByExactId(catalog: ToolSearchCatalogSession, id: string): ToolSearchCatalogEntry { +function findEntryByExactId( + catalog: ToolSearchCatalogSession, + id: string, + errorOptions: UnknownToolErrorOptions = {}, +): ToolSearchCatalogEntry { const needle = id.trim(); const entry = catalog.entries.find((candidate) => candidate.id === needle); if (!entry) { - throw new ToolInputError(`Unknown tool id: ${needle}`); + throw new ToolInputError( + formatUnknownToolIdError(needle, catalog.entries, { ...errorOptions, exactIdOnly: true }), + ); } return entry; } @@ -1670,10 +1745,10 @@ export class ToolSearchRuntime { ); }; - describe = async (id: string, options?: CatalogVisibilityOptions) => { + describe = async (id: string, options?: CatalogVisibilityOptions & UnknownToolErrorOptions) => { const catalog = resolveCatalog(this.ctx); catalog.describeCount += 1; - return describeEntry(findEntry(catalog, id, options)); + return describeEntry(findEntry(catalog, id, options, options)); }; call = async ( @@ -1683,10 +1758,11 @@ export class ToolSearchRuntime { parentToolCallId?: string; signal?: AbortSignal; onUpdate?: AgentToolUpdateCallback; + recoverySurface?: UnknownToolRecoverySurface; }, ) => { const catalog = resolveCatalog(this.ctx); - const entry = findEntry(catalog, id); + const entry = findEntry(catalog, id, undefined, options); return await this.callEntry(catalog, entry, input, options); }; @@ -1697,10 +1773,11 @@ export class ToolSearchRuntime { parentToolCallId?: string; signal?: AbortSignal; onUpdate?: AgentToolUpdateCallback; + recoverySurface?: UnknownToolRecoverySurface; }, ) => { const catalog = resolveCatalog(this.ctx); - const entry = findEntryByExactId(catalog, id); + const entry = findEntryByExactId(catalog, id, options); return await this.callEntry(catalog, entry, input, options); }; @@ -2000,14 +2077,17 @@ async function runCodeModeBridgeRequest( if (typeof id !== "string") { throw new ToolInputError("describe id must be a string."); } - return await runtime.describe(id); + return await runtime.describe(id, { recoverySurface: "code-mode" }); } case "call": { const id = values[0]; if (typeof id !== "string") { throw new ToolInputError("call id must be a string."); } - return await runtime.call(id, values[1] ?? {}, options); + return await runtime.call(id, values[1] ?? {}, { + ...options, + recoverySurface: "code-mode", + }); } } throw new ToolInputError("Unsupported tool_search_code bridge method.");