mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-08 02:52:15 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user