From 5d1ebe624b91ff7f62bfbf4c42608974349aff3a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 4 Jun 2026 09:33:22 +0200 Subject: [PATCH] fix(agents): skip unreadable tool search catalog entries --- src/agents/tool-search.test.ts | 26 +++++++++++++++ src/agents/tool-search.ts | 58 +++++++++++++++++++++++++++------- 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/src/agents/tool-search.test.ts b/src/agents/tool-search.test.ts index 3b689152c10e..4efc1dfdb054 100644 --- a/src/agents/tool-search.test.ts +++ b/src/agents/tool-search.test.ts @@ -158,6 +158,32 @@ describe("Tool Search", () => { expect(telemetry.callCount).toBe(1); }); + it("skips catalog tools with unreadable schemas without dropping healthy siblings", () => { + const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode"); + const broken = pluginTool("fake_broken", "Broken target"); + Object.defineProperty(broken, "parameters", { + enumerable: true, + get() { + throw new Error("fuzzplugin parameters getter exploded"); + }, + }); + const healthy = pluginTool("fake_healthy", "Healthy target"); + + const compacted = applyToolSearchCatalog({ + tools: [codeTool, broken, healthy], + config: { + tools: { + toolSearch: true, + }, + } as never, + sessionId: "session-unreadable-schema", + sessionKey: "agent:main:unreadable-schema", + }); + + expect(compacted.tools.map((tool) => tool.name)).toEqual([TOOL_SEARCH_CODE_MODE_TOOL_NAME]); + expect(compacted.catalogToolCount).toBe(1); + }); + it("scopes catalogs by run id when attempts share a session", async () => { const runATool = pluginTool("fake_run_a", "Tool visible only to run A"); const runBTool = pluginTool("fake_run_b", "Tool visible only to run B"); diff --git a/src/agents/tool-search.ts b/src/agents/tool-search.ts index a4f49b2373ac..f12ceb1b6a21 100644 --- a/src/agents/tool-search.ts +++ b/src/agents/tool-search.ts @@ -638,9 +638,35 @@ function classifyTool(tool: CatalogTool): { return { source: "openclaw", sourceName: "core" }; } -function makeCatalogId(tool: CatalogTool, source: CatalogSource, sourceName?: string): string { +function makeCatalogId(name: string, source: CatalogSource, sourceName?: string): string { const owner = sourceName?.trim() || "core"; - return `${source}:${owner}:${tool.name}`; + return `${source}:${owner}:${name}`; +} + +type CatalogToolSnapshot = { + name: string; + label?: string; + description: string; + parameters?: unknown; +}; + +function readCatalogToolSnapshot(tool: CatalogTool): CatalogToolSnapshot | undefined { + try { + const name = tool.name; + if (typeof name !== "string" || !name.trim()) { + return undefined; + } + const label = typeof tool.label === "string" ? tool.label : undefined; + const description = typeof tool.description === "string" ? tool.description : ""; + return { + name, + ...(label !== undefined ? { label } : {}), + description, + parameters: tool.parameters, + }; + } catch { + return undefined; + } } function wrapCatalogTool(tool: AnyAgentTool, hookContext?: HookContext): AnyAgentTool { @@ -654,21 +680,25 @@ function toCatalogEntry( tool: CatalogTool, sourceOverride?: CatalogSource, hookContext?: HookContext, -): ToolSearchCatalogEntry { +): ToolSearchCatalogEntry | undefined { + const snapshot = readCatalogToolSnapshot(tool); + if (!snapshot) { + return undefined; + } const classified = classifyTool(tool); const source = sourceOverride ?? classified.source; const sourceName = sourceOverride === "client" ? "client" : classified.sourceName; const catalogTool = source === "client" ? tool : wrapCatalogTool(tool as AnyAgentTool, hookContext); return { - id: makeCatalogId(tool, source, sourceName), + id: makeCatalogId(snapshot.name, source, sourceName), source, sourceName, ...(source === "mcp" && classified.mcp ? { mcp: classified.mcp } : {}), - name: tool.name, - label: tool.label, - description: tool.description ?? "", - parameters: tool.parameters, + name: snapshot.name, + label: snapshot.label, + description: snapshot.description, + parameters: snapshot.parameters, tool: catalogTool, }; } @@ -1292,7 +1322,10 @@ export function applyToolCatalogCompaction(params: { continue; } if (shouldCatalog(tool)) { - catalog.push(toCatalogEntry(tool, undefined, params.toolHookContext)); + const entry = toCatalogEntry(tool, undefined, params.toolHookContext); + if (entry) { + catalog.push(entry); + } continue; } visible.push(tool); @@ -1382,16 +1415,19 @@ export function addClientToolsToToolCatalog(params: { if (!existing) { return { tools: params.tools, compacted: false, catalogToolCount: 0 }; } + const entries = params.tools + .map((tool) => toCatalogEntry(tool, "client")) + .filter((entry): entry is ToolSearchCatalogEntry => Boolean(entry)); registerToolSearchCatalog({ sessionId: params.sessionId, sessionKey: params.sessionKey, agentId: params.agentId, runId: params.runId, catalogRef: params.catalogRef, - entries: params.tools.map((tool) => toCatalogEntry(tool, "client")), + entries, append: true, }); - return { tools: [], compacted: params.tools.length > 0, catalogToolCount: params.tools.length }; + return { tools: [], compacted: entries.length > 0, catalogToolCount: entries.length }; } function toJsonSafe(value: unknown): unknown {