fix(agents): suggest recovery for unknown tool ids (#93374)

Merged via squash.

Prepared head SHA: bee84e4eb8
Co-authored-by: mushuiyu886 <266724580+mushuiyu886@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
This commit is contained in:
mushuiyu886
2026-06-23 14:20:09 +08:00
committed by GitHub
parent f0a2ba0584
commit 01abe0a33d
5 changed files with 360 additions and 43 deletions

View File

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

View File

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

View File

@@ -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<WebAssembly.Module> | 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<CodeModeWorkerResult> {
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 : [],
};
}

View File

@@ -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 () => {

View File

@@ -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<string> {
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<string, number>();
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.");