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 <sebtardif@ncf.ca>
This commit is contained in:
Sebastien Tardif
2026-05-27 12:56:06 -07:00
committed by GitHub
parent d93524d1cc
commit 60e8e60306
3 changed files with 351 additions and 3 deletions

View File

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

View File

@@ -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);
});
});

View File

@@ -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<string, ToolSearchCatalogSession>());
const reusableCatalogSnapshots = new Map<string, ReusableCatalogSnapshot>();
const catalogFingerprints = new WeakMap<ToolSearchCatalogSession, string>();
const catalogToolIdentities = new WeakMap<object, number>();
let nextCatalogToolIdentity = 1;
function readToolSearchConfig(config?: OpenClawConfig): Record<string, unknown> {
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<object>()): 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<string, unknown>;
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) => {