From 60e8e60306e8aef21549cd40eb77454274c5be17 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 27 May 2026 12:56:06 -0700 Subject: [PATCH] fix(tool-search): reuse unchanged catalogs Fixes repeated Tool Search catalog registration for unchanged effective tool sets by reusing a fingerprinted catalog snapshot across embedded-agent run cleanup. The reusable catalog is guarded by catalog-affecting fields, parameters, and executable identity, and reuse now rebinds the current run/session refs before returning. Embedded-agent prep logging only suppresses the catalog line when reuse actually happened. Verification: - pnpm test src/agents/tool-search.test.ts -- --reporter=verbose - pnpm check:changed, Testbox tbx_01ksney4f00wgk9n39yv7jsh4m - Real behavior proof, GitHub Actions run 26534896284 - CI rerun for unrelated model-picker timeout passed, GitHub Actions run 26534489215 - autoreview clean: no accepted/actionable findings Closes #86887 Co-authored-by: Sebastien Tardif --- .../embedded-agent-runner/run/attempt.ts | 2 +- src/agents/tool-search.test.ts | 144 ++++++++++++ src/agents/tool-search.ts | 208 +++++++++++++++++- 3 files changed, 351 insertions(+), 3 deletions(-) diff --git a/src/agents/embedded-agent-runner/run/attempt.ts b/src/agents/embedded-agent-runner/run/attempt.ts index 6820b292fdfa..e4c6f1f33589 100644 --- a/src/agents/embedded-agent-runner/run/attempt.ts +++ b/src/agents/embedded-agent-runner/run/attempt.ts @@ -2088,7 +2088,7 @@ export async function runEmbeddedAttempt( sessionId: params.sessionId, }); effectiveTools = [...toolSearchSchemaProjection.tools]; - if (toolSearch.compacted) { + if (toolSearch.compacted && !toolSearch.catalogReused) { prepStages.mark(codeModeControlsEnabledForRun ? "code-mode" : "tool-search"); log.info( codeModeControlsEnabledForRun diff --git a/src/agents/tool-search.test.ts b/src/agents/tool-search.test.ts index 94258887f37f..b99f3a89d5bf 100644 --- a/src/agents/tool-search.test.ts +++ b/src/agents/tool-search.test.ts @@ -874,4 +874,148 @@ describe("Tool Search", () => { expect(observedSignal.aborted).toBe(true); expect(abortCount).toBe(1); }); + + it("reuses an unchanged catalog within the same run", () => { + const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode"); + const alpha = pluginTool("fake_reuse_alpha", "Alpha tool"); + const beta = pluginTool("fake_reuse_beta", "Beta tool"); + const config = { tools: { toolSearch: true } } as never; + const sessionId = "session-catalog-reuse"; + + const first = applyToolSearchCatalog({ + tools: [codeTool, alpha, beta], + config, + sessionId, + }); + expect(first.catalogRegistered).toBe(true); + expect(first.catalogReused).toBe(false); + + const catalogAfterFirst = testing.sessionCatalogs.get(`session:${sessionId}`); + expect(catalogAfterFirst).toBeDefined(); + + const second = applyToolSearchCatalog({ + tools: [codeTool, alpha, beta], + config, + sessionId, + }); + expect(second.catalogRegistered).toBe(true); + expect(second.catalogReused).toBe(true); + expect(testing.sessionCatalogs.get(`session:${sessionId}`)).toBe(catalogAfterFirst); + + const laterRef = createToolSearchCatalogRef(); + const later = applyToolSearchCatalog({ + tools: [codeTool, alpha, beta], + config, + sessionId, + sessionKey: "agent:main:tool-search-reuse", + catalogRef: laterRef, + }); + expect(later.catalogReused).toBe(true); + expect(laterRef.current).toBe(catalogAfterFirst); + expect(testing.sessionCatalogs.get("key:agent:main:tool-search-reuse")).toBe(catalogAfterFirst); + }); + + it("restores an unchanged catalog after run cleanup", () => { + const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode"); + const alpha = pluginTool("fake_xrun_alpha", "Alpha tool"); + const beta = pluginTool("fake_xrun_beta", "Beta tool"); + const config = { tools: { toolSearch: true } } as never; + const sessionId = "session-cross-run-reuse"; + const firstRef = createToolSearchCatalogRef(); + + const first = applyToolSearchCatalog({ + tools: [codeTool, alpha, beta], + config, + sessionId, + runId: "run-1", + catalogRef: firstRef, + }); + expect(first.catalogReused).toBe(false); + const firstAlphaEntry = firstRef.current?.entries.find((entry) => entry.name === alpha.name); + expect(firstAlphaEntry).toBeDefined(); + + clearToolSearchCatalog({ + sessionId, + runId: "run-1", + catalogRef: firstRef, + }); + expect(firstRef.current).toBeUndefined(); + expect(testing.sessionCatalogs.has("run:run-1")).toBe(false); + + const secondRef = createToolSearchCatalogRef(); + const second = applyToolSearchCatalog({ + tools: [codeTool, alpha, beta], + config, + sessionId, + runId: "run-2", + catalogRef: secondRef, + }); + expect(second.catalogRegistered).toBe(true); + expect(second.catalogReused).toBe(true); + expect(testing.sessionCatalogs.has("run:run-2")).toBe(true); + expect(secondRef.current?.entries.find((entry) => entry.name === alpha.name)).toBe( + firstAlphaEntry, + ); + }); + + it("does not reuse when a same-named tool uses a different executable", () => { + const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode"); + const original = pluginTool("fake_exec_swap", "Stable description"); + const config = { tools: { toolSearch: true } } as never; + const sessionId = "session-tool-exec-change"; + const firstRef = createToolSearchCatalogRef(); + + applyToolSearchCatalog({ + tools: [codeTool, original], + config, + sessionId, + runId: "run-exec-1", + catalogRef: firstRef, + }); + clearToolSearchCatalog({ + sessionId, + runId: "run-exec-1", + catalogRef: firstRef, + }); + + const replacement = pluginTool("fake_exec_swap", "Stable description"); + const secondRef = createToolSearchCatalogRef(); + const second = applyToolSearchCatalog({ + tools: [codeTool, replacement], + config, + sessionId, + runId: "run-exec-2", + catalogRef: secondRef, + }); + expect(second.catalogReused).toBe(false); + expect(secondRef.current?.entries.find((entry) => entry.name === replacement.name)?.tool).toBe( + replacement, + ); + }); + + it("does not reuse when a same-named tool changes parameters", () => { + const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode"); + const tool = pluginTool("fake_schema_swap", "Stable description"); + const config = { tools: { toolSearch: true } } as never; + const sessionId = "session-tool-schema-change"; + + applyToolSearchCatalog({ + tools: [codeTool, tool], + config, + sessionId, + }); + tool.parameters = { + type: "object", + properties: { + other: { type: "number" }, + }, + }; + + const second = applyToolSearchCatalog({ + tools: [codeTool, tool], + config, + sessionId, + }); + expect(second.catalogReused).toBe(false); + }); }); diff --git a/src/agents/tool-search.ts b/src/agents/tool-search.ts index df7934e61074..c8c7ee5f30f6 100644 --- a/src/agents/tool-search.ts +++ b/src/agents/tool-search.ts @@ -34,11 +34,17 @@ const TOOL_SEARCH_CONTROL_TOOL_NAMES = new Set([ const DEFAULT_CODE_TIMEOUT_MS = 10_000; const DEFAULT_SEARCH_LIMIT = 8; const DEFAULT_MAX_SEARCH_LIMIT = 20; +const MAX_REUSABLE_CATALOG_SNAPSHOTS = 256; type ToolSearchMode = "code" | "tools"; type CatalogSource = "openclaw" | "mcp" | "client"; type CatalogTool = AnyAgentTool | ToolDefinition; +type ReusableCatalogSnapshot = { + entries: ToolSearchCatalogEntry[]; + fingerprint: string; +}; + export type ToolSearchCatalogToolExecutor = (params: { tool: CatalogTool; toolName: string; @@ -371,6 +377,10 @@ const globalToolSearchState = globalThis as typeof globalThis & { const sessionCatalogs = globalToolSearchState[SESSION_CATALOGS_KEY] ?? (globalToolSearchState[SESSION_CATALOGS_KEY] = new Map()); +const reusableCatalogSnapshots = new Map(); +const catalogFingerprints = new WeakMap(); +const catalogToolIdentities = new WeakMap(); +let nextCatalogToolIdentity = 1; function readToolSearchConfig(config?: OpenClawConfig): Record { const tools = isRecord(config?.tools) ? config.tools : undefined; @@ -460,6 +470,133 @@ function sessionCatalogKey(input: { return sessionCatalogKeys(input)[0]; } +function reusableCatalogKey(input: { + sessionId?: string; + sessionKey?: string; + agentId?: string; +}): string | undefined { + return sessionCatalogKey({ + sessionId: input.sessionId, + sessionKey: input.sessionKey, + agentId: input.agentId, + }); +} + +function stableJsonFingerprint(value: unknown, seen = new WeakSet()): string { + if (value === null || typeof value !== "object") { + return JSON.stringify(value) ?? "undefined"; + } + if (seen.has(value)) { + return '"[Circular]"'; + } + seen.add(value); + if (Array.isArray(value)) { + return `[${value.map((item) => stableJsonFingerprint(item, seen)).join(",")}]`; + } + const record = value as Record; + const entries = Object.keys(record) + .toSorted() + .map((key) => `${JSON.stringify(key)}:${stableJsonFingerprint(record[key], seen)}`); + return `{${entries.join(",")}}`; +} + +function catalogToolIdentity(tool: CatalogTool): number { + const existing = catalogToolIdentities.get(tool); + if (existing !== undefined) { + return existing; + } + const next = nextCatalogToolIdentity; + nextCatalogToolIdentity += 1; + catalogToolIdentities.set(tool, next); + return next; +} + +function catalogEntriesFingerprint(entries: readonly ToolSearchCatalogEntry[]): string { + return entries + .map((entry) => + [ + entry.id, + entry.source, + entry.sourceName ?? "", + entry.name, + entry.label ?? "", + entry.description, + stableJsonFingerprint(entry.parameters), + String(catalogToolIdentity(entry.tool)), + ] + .map((part) => JSON.stringify(part)) + .join(":"), + ) + .toSorted() + .join("\n"); +} + +function restoreToolSearchCatalog(params: { + sessionId?: string; + sessionKey?: string; + agentId?: string; + runId?: string; + catalogRef?: ToolSearchCatalogRef; + entries: ToolSearchCatalogEntry[]; + fingerprint: string; +}): ToolSearchCatalogSession | undefined { + const keys = sessionCatalogKeys(params); + if (keys.length === 0 && !params.catalogRef) { + return undefined; + } + const next = { + entries: params.entries, + searchCount: 0, + describeCount: 0, + callCount: 0, + }; + if (params.catalogRef) { + params.catalogRef.current = next; + } + catalogFingerprints.set(next, params.fingerprint); + for (const key of keys) { + sessionCatalogs.set(key, next); + } + return next; +} + +function bindToolSearchCatalog(params: { + sessionId?: string; + sessionKey?: string; + agentId?: string; + runId?: string; + catalogRef?: ToolSearchCatalogRef; + catalog: ToolSearchCatalogSession; +}): void { + if (params.catalogRef) { + params.catalogRef.current = params.catalog; + } + for (const key of sessionCatalogKeys(params)) { + sessionCatalogs.set(key, params.catalog); + } +} + +function rememberReusableCatalog(key: string | undefined, catalog: ToolSearchCatalogSession): void { + if (!key) { + return; + } + const fingerprint = catalogFingerprints.get(catalog); + if (!fingerprint) { + return; + } + if (reusableCatalogSnapshots.has(key)) { + reusableCatalogSnapshots.delete(key); + } + reusableCatalogSnapshots.set(key, { entries: catalog.entries, fingerprint }); + while (reusableCatalogSnapshots.size > MAX_REUSABLE_CATALOG_SNAPSHOTS) { + const oldestKey = reusableCatalogSnapshots.keys().next().value; + if (!oldestKey) { + break; + } + reusableCatalogSnapshots.delete(oldestKey); + } +} + function classifyTool(tool: CatalogTool): { source: CatalogSource; sourceName?: string } { const meta = getPluginToolMeta(tool as AnyAgentTool); const pluginId = meta?.pluginId?.trim(); @@ -688,6 +825,7 @@ export function applyToolSearchCatalog(params: { compacted: boolean; catalogToolCount: number; catalogRegistered: boolean; + catalogReused: boolean; } { const config = resolveToolSearchConfig(params.config); return applyToolCatalogCompaction({ @@ -745,6 +883,7 @@ export function registerToolSearchCatalog(params: { describeCount: prior?.describeCount ?? 0, callCount: prior?.callCount ?? 0, }; + catalogFingerprints.set(next, catalogEntriesFingerprint(next.entries)); if (params.catalogRef) { params.catalogRef.current = next; } @@ -767,6 +906,12 @@ export function clearToolSearchCatalog(params: { for (const key of sessionCatalogKeys(params)) { sessionCatalogs.delete(key); } + if (!params.runId?.trim()) { + const snapshotKey = reusableCatalogKey(params); + if (snapshotKey) { + reusableCatalogSnapshots.delete(snapshotKey); + } + } } function resolveCatalog(ctx: ToolSearchToolContext): ToolSearchCatalogSession { @@ -1017,9 +1162,16 @@ export function applyToolCatalogCompaction(params: { compacted: boolean; catalogToolCount: number; catalogRegistered: boolean; + catalogReused: boolean; } { if (!params.enabled) { - return { tools: params.tools, compacted: false, catalogToolCount: 0, catalogRegistered: false }; + return { + tools: params.tools, + compacted: false, + catalogToolCount: 0, + catalogRegistered: false, + catalogReused: false, + }; } const hasControlTool = params.tools.some((tool) => params.isVisibleControlTool(tool)); const key = sessionCatalogKey(params); @@ -1029,6 +1181,7 @@ export function applyToolCatalogCompaction(params: { compacted: false, catalogToolCount: 0, catalogRegistered: false, + catalogReused: false, }; } @@ -1049,7 +1202,53 @@ export function applyToolCatalogCompaction(params: { } visible.push(tool); } - registerToolSearchCatalog({ + const incomingFingerprint = catalogEntriesFingerprint(catalog); + const existingCatalog = + params.catalogRef?.current ?? (key ? sessionCatalogs.get(key) : undefined); + if (existingCatalog && catalogFingerprints.get(existingCatalog) === incomingFingerprint) { + bindToolSearchCatalog({ + sessionId: params.sessionId, + sessionKey: params.sessionKey, + agentId: params.agentId, + runId: params.runId, + catalogRef: params.catalogRef, + catalog: existingCatalog, + }); + return { + tools: visible, + compacted: catalog.length > 0, + catalogToolCount: catalog.length, + catalogRegistered: true, + catalogReused: true, + }; + } + + const reusableKey = reusableCatalogKey(params); + const reusableSnapshot = reusableKey ? reusableCatalogSnapshots.get(reusableKey) : undefined; + if (reusableSnapshot?.fingerprint === incomingFingerprint) { + restoreToolSearchCatalog({ + sessionId: params.sessionId, + sessionKey: params.sessionKey, + agentId: params.agentId, + runId: params.runId, + catalogRef: params.catalogRef, + entries: reusableSnapshot.entries, + fingerprint: reusableSnapshot.fingerprint, + }); + if (reusableKey) { + reusableCatalogSnapshots.delete(reusableKey); + reusableCatalogSnapshots.set(reusableKey, reusableSnapshot); + } + return { + tools: visible, + compacted: catalog.length > 0, + catalogToolCount: catalog.length, + catalogRegistered: true, + catalogReused: true, + }; + } + + const registered = registerToolSearchCatalog({ sessionId: params.sessionId, sessionKey: params.sessionKey, agentId: params.agentId, @@ -1058,11 +1257,15 @@ export function applyToolCatalogCompaction(params: { entries: catalog, append: false, }); + if (registered) { + rememberReusableCatalog(reusableKey, registered); + } return { tools: visible, compacted: catalog.length > 0, catalogToolCount: catalog.length, catalogRegistered: true, + catalogReused: false, }; } @@ -1443,6 +1646,7 @@ export function createToolSearchTools(ctx: ToolSearchToolContext): AnyAgentTool[ export const testing = { sessionCatalogs, + reusableCatalogSnapshots, resolveToolSearchConfig, isToolSearchCodeModeSupported, setToolSearchCodeModeSupportedForTest: (value: boolean | undefined) => {