From 050475b9de8f008e4985bbb00d6aa5363bfa1f0d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 24 Jul 2026 03:04:14 -0700 Subject: [PATCH] refactor(agents): split tool search internals (#113296) --- config/max-lines-baseline.txt | 1 - src/agents/tool-search-catalog.ts | 547 +++++++ src/agents/tool-search-code-mode.ts | 261 +++ src/agents/tool-search-config.ts | 79 + src/agents/tool-search-directory.ts | 486 ++++++ src/agents/tool-search-json.ts | 29 + src/agents/tool-search-runtime.ts | 429 +++++ src/agents/tool-search-transcript.ts | 172 ++ src/agents/tool-search-types.ts | 153 ++ src/agents/tool-search.ts | 2265 +------------------------- 10 files changed, 2243 insertions(+), 2179 deletions(-) create mode 100644 src/agents/tool-search-catalog.ts create mode 100644 src/agents/tool-search-code-mode.ts create mode 100644 src/agents/tool-search-config.ts create mode 100644 src/agents/tool-search-directory.ts create mode 100644 src/agents/tool-search-json.ts create mode 100644 src/agents/tool-search-runtime.ts create mode 100644 src/agents/tool-search-transcript.ts create mode 100644 src/agents/tool-search-types.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 1bf139e1630a..c05f05e29d41 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -530,7 +530,6 @@ src/agents/system-prompt.ts src/agents/tool-display-common.ts src/agents/tool-loop-detection.test.ts src/agents/tool-search.test.ts -src/agents/tool-search.ts src/agents/tools/computer-tool.ts src/agents/tools/cron-tool.test.ts src/agents/tools/cron-tool.ts diff --git a/src/agents/tool-search-catalog.ts b/src/agents/tool-search-catalog.ts new file mode 100644 index 000000000000..5ce56e2acf48 --- /dev/null +++ b/src/agents/tool-search-catalog.ts @@ -0,0 +1,547 @@ +import { uniqueStrings, uniqueValues } from "@openclaw/normalization-core/string-normalization"; +import { getPluginToolMeta, type PluginToolMcpMeta } from "../plugins/tools.js"; +import type { HookContext } from "./agent-tools.before-tool-call.js"; +import { + isToolWrappedWithBeforeToolCallHook, + rewrapToolWithBeforeToolCallHook, + wrapToolWithBeforeToolCallHook, +} from "./agent-tools.before-tool-call.js"; +import type { ToolDefinition } from "./sessions/index.js"; +import { compactToolInputHint, compactToolOutputHint } from "./tool-schema-hints.js"; +import { + TOOL_SEARCH_CONTROL_TOOL_NAMES, + type CatalogSource, + type CatalogTool, + type CatalogVisibilityOptions, + type ToolSearchCatalogApplyResult, + type ToolSearchCatalogCompactionParams, + type ToolSearchCatalogEntry, + type ToolSearchCatalogRef, + type ToolSearchCatalogSession, + type ToolSearchToolContext, +} from "./tool-search-types.js"; +import { ToolInputError, type AnyAgentTool } from "./tools/common.js"; + +const MAX_REUSABLE_CATALOG_SNAPSHOTS = 256; +const SESSION_CATALOGS_KEY = Symbol.for("openclaw.toolSearch.sessionCatalogs"); +const globalToolSearchState = globalThis as typeof globalThis & { + [SESSION_CATALOGS_KEY]?: Map; +}; + +export const sessionCatalogs = + globalToolSearchState[SESSION_CATALOGS_KEY] ?? + (globalToolSearchState[SESSION_CATALOGS_KEY] = new Map()); +export const reusableCatalogSnapshots = new Map< + string, + { entries: ToolSearchCatalogEntry[]; fingerprint: string } +>(); +const catalogFingerprints = new WeakMap(); +const catalogToolIdentities = new WeakMap(); +const untrustedSchemaIdentities = new WeakMap(); +let nextCatalogToolIdentity = 1; +let nextUntrustedSchemaIdentity = 1; + +function sessionCatalogKeys(input: { + sessionId?: string; + sessionKey?: string; + agentId?: string; + runId?: string; +}): string[] { + const runId = input.runId?.trim(); + if (runId) { + return [`run:${runId}`]; + } + const keys: string[] = []; + if (input.sessionId?.trim()) { + keys.push(`session:${input.sessionId.trim()}`); + } + if (input.sessionKey?.trim()) { + keys.push(`key:${input.sessionKey.trim()}`); + } + if (input.agentId?.trim()) { + keys.push(`agent:${input.agentId.trim()}`); + } + return uniqueStrings(keys); +} + +function sessionCatalogKey(input: { + sessionId?: string; + sessionKey?: string; + agentId?: string; + runId?: string; +}): string | undefined { + 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 untrustedSchemaFingerprint(schema: unknown): string { + if (schema === null || typeof schema !== "object") { + return stableJsonFingerprint(schema); + } + const existing = untrustedSchemaIdentities.get(schema); + if (existing !== undefined) { + return `object:${existing}`; + } + const next = nextUntrustedSchemaIdentity; + nextUntrustedSchemaIdentity += 1; + untrustedSchemaIdentities.set(schema, next); + return `object:${next}`; +} + +function catalogEntriesFingerprint(entries: readonly ToolSearchCatalogEntry[]): string { + // Executable identities are part of reuse because function bodies are not JSON-stable. + return entries + .map((entry) => + [ + entry.id, + entry.source, + entry.sourceName ?? "", + stableJsonFingerprint(entry.mcp), + entry.name, + entry.label ?? "", + entry.description, + // Remote/client schemas may be attacker-sized. Object identity still + // invalidates reuse when a schema object is replaced without walking it. + entry.source === "openclaw" + ? stableJsonFingerprint(entry.parameters) + : untrustedSchemaFingerprint(entry.parameters), + entry.source === "openclaw" + ? stableJsonFingerprint(entry.outputSchema) + : untrustedSchemaFingerprint(entry.outputSchema), + 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); + } +} + +export function classifyTool(tool: CatalogTool): { + source: CatalogSource; + sourceName?: string; + mcp?: PluginToolMcpMeta; +} { + const meta = getPluginToolMeta(tool as AnyAgentTool); + const pluginId = meta?.pluginId?.trim(); + const mcp = meta?.mcp; + if (mcp) { + return { source: "mcp", sourceName: mcp.safeServerName || pluginId || "mcp", mcp }; + } + if (pluginId === "bundle-mcp") { + return { source: "mcp", sourceName: pluginId }; + } + if (pluginId) { + return { source: "openclaw", sourceName: pluginId }; + } + return { source: "openclaw", sourceName: "core" }; +} + +function makeCatalogId(tool: CatalogTool, source: CatalogSource, sourceName?: string): string { + const owner = sourceName?.trim() || "core"; + return `${source}:${owner}:${tool.name}`; +} + +function wrapCatalogTool(tool: AnyAgentTool, hookContext?: HookContext): AnyAgentTool { + if (!hookContext || isToolWrappedWithBeforeToolCallHook(tool)) { + return tool; + } + return wrapToolWithBeforeToolCallHook(tool, hookContext); +} + +function toCatalogEntry( + tool: CatalogTool, + sourceOverride?: CatalogSource, + hookContext?: HookContext, +): ToolSearchCatalogEntry { + 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), + source, + sourceName, + ...(source === "mcp" && classified.mcp ? { mcp: classified.mcp } : {}), + name: tool.name, + label: tool.label, + description: tool.description ?? "", + parameters: tool.parameters, + ...(source === "openclaw" && (tool as AnyAgentTool).outputSchema + ? { outputSchema: (tool as AnyAgentTool).outputSchema } + : {}), + tool: catalogTool, + }; +} + +function shouldCatalogTool(tool: AnyAgentTool): boolean { + return !TOOL_SEARCH_CONTROL_TOOL_NAMES.has(tool.name) && tool.catalogMode !== "direct-only"; +} + +export function registerHeadlessToolSearchCatalog(params: { + catalogRef: ToolSearchCatalogRef; + tools: readonly AnyAgentTool[]; + hookContext?: HookContext; +}): void { + const { catalogRef, tools, hookContext } = params; + const entries = tools + .filter((tool) => shouldCatalogTool(tool)) + .map((tool) => { + const scopedTool = + hookContext && isToolWrappedWithBeforeToolCallHook(tool) + ? rewrapToolWithBeforeToolCallHook(tool, hookContext) + : tool; + return toCatalogEntry(scopedTool, undefined, hookContext); + }); + registerToolSearchCatalog({ catalogRef, entries }); +} + +export function collectUniqueCatalogToolNames(tools: readonly AnyAgentTool[]): Set { + const nameCounts = new Map(); + for (const tool of tools) { + if (shouldCatalogTool(tool)) { + nameCounts.set(tool.name, (nameCounts.get(tool.name) ?? 0) + 1); + } + } + return new Set( + Array.from(nameCounts) + .filter(([, count]) => count === 1) + .map(([name]) => name), + ); +} + +function registerToolSearchCatalog(params: { + sessionId?: string; + sessionKey?: string; + agentId?: string; + runId?: string; + catalogRef?: ToolSearchCatalogRef; + entries: ToolSearchCatalogEntry[]; + append?: boolean; +}): ToolSearchCatalogSession | undefined { + const keys = sessionCatalogKeys(params); + const primaryKey = keys[0]; + if (!primaryKey && !params.catalogRef) { + return undefined; + } + const prior = params.append + ? (params.catalogRef?.current ?? (primaryKey ? sessionCatalogs.get(primaryKey) : undefined)) + : undefined; + const byId = new Map(); + for (const entry of prior?.entries ?? []) { + byId.set(entry.id, entry); + } + for (const entry of params.entries) { + byId.set(entry.id, entry); + byId.set(entry.name, entry); + } + const next = { + entries: uniqueValues(byId.values()).toSorted((a, b) => a.id.localeCompare(b.id)), + searchCount: prior?.searchCount ?? 0, + describeCount: prior?.describeCount ?? 0, + callCount: prior?.callCount ?? 0, + }; + catalogFingerprints.set(next, catalogEntriesFingerprint(next.entries)); + if (params.catalogRef) { + params.catalogRef.current = next; + } + for (const key of keys) { + sessionCatalogs.set(key, next); + } + return next; +} + +export function clearToolSearchCatalog(params: { + sessionId?: string; + sessionKey?: string; + agentId?: string; + runId?: string; + catalogRef?: ToolSearchCatalogRef; +}): void { + if (params.catalogRef) { + params.catalogRef.current = undefined; + } + for (const key of sessionCatalogKeys(params)) { + sessionCatalogs.delete(key); + } + if (!params.runId?.trim()) { + const snapshotKey = reusableCatalogKey(params); + if (snapshotKey) { + reusableCatalogSnapshots.delete(snapshotKey); + } + } +} + +export function resolveCatalog(ctx: ToolSearchToolContext): ToolSearchCatalogSession { + if (ctx.catalogRef?.current) { + return ctx.catalogRef.current; + } + const keys = sessionCatalogKeys(ctx); + for (const key of keys) { + const catalog = sessionCatalogs.get(key); + if (catalog) { + return catalog; + } + } + if (ctx.runId?.trim()) { + throw new ToolInputError("Tool Search catalog is unavailable for this run."); + } + const uniqueCatalogs = uniqueValues(sessionCatalogs.values()); + if (uniqueCatalogs.length === 1 && uniqueCatalogs[0]) { + return uniqueCatalogs[0]; + } + throw new ToolInputError("Tool Search catalog is unavailable for this run."); +} + +export function visibleCatalogEntries( + catalog: ToolSearchCatalogSession, + options?: CatalogVisibilityOptions, +): ToolSearchCatalogEntry[] { + return options?.includeMcp === false + ? catalog.entries.filter((entry) => entry.source !== "mcp") + : catalog.entries; +} + +export function compactToolSearchCatalogEntry(entry: ToolSearchCatalogEntry) { + const output = + entry.source === "openclaw" ? compactToolOutputHint(entry.outputSchema) : undefined; + return { + id: entry.id, + source: entry.source, + sourceName: entry.sourceName, + ...(entry.mcp ? { mcp: entry.mcp } : {}), + name: entry.name, + label: entry.label, + description: entry.description, + input: entry.source === "openclaw" ? compactToolInputHint(entry.parameters) : "unknown", + ...(output ? { output } : {}), + }; +} + +export function createToolSearchCatalogRef(): ToolSearchCatalogRef { + return {}; +} + +export function applyToolCatalogCompaction( + params: ToolSearchCatalogCompactionParams, +): ToolSearchCatalogApplyResult { + if (!params.enabled) { + 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); + if (!hasControlTool || (!key && !params.catalogRef)) { + return { + tools: params.tools.filter((tool) => !TOOL_SEARCH_CONTROL_TOOL_NAMES.has(tool.name)), + compacted: false, + catalogToolCount: 0, + catalogRegistered: false, + catalogReused: false, + }; + } + + const visible: AnyAgentTool[] = []; + const catalog: ToolSearchCatalogEntry[] = []; + const shouldCatalog = (tool: AnyAgentTool) => + shouldCatalogTool(tool) && (params.shouldCatalogTool?.(tool) ?? true); + for (const tool of params.tools) { + if (params.isVisibleControlTool(tool)) { + visible.push(tool); + continue; + } + if (TOOL_SEARCH_CONTROL_TOOL_NAMES.has(tool.name)) { + continue; + } + if (shouldCatalog(tool)) { + catalog.push(toCatalogEntry(tool, undefined, params.toolHookContext)); + if (!params.isVisibleCatalogTool?.(tool)) { + continue; + } + } + visible.push(tool); + } + const incomingFingerprint = catalogEntriesFingerprint(catalog); + const existingCatalog = + params.catalogRef?.current ?? (key ? sessionCatalogs.get(key) : undefined); + if (existingCatalog && catalogFingerprints.get(existingCatalog) === incomingFingerprint) { + bindToolSearchCatalog({ ...params, 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({ + ...params, + 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({ ...params, entries: catalog, append: false }); + if (registered) { + rememberReusableCatalog(reusableKey, registered); + } + return { + tools: visible, + compacted: catalog.length > 0, + catalogToolCount: catalog.length, + catalogRegistered: true, + catalogReused: false, + }; +} + +export function addClientToolsToToolCatalog(params: { + tools: ToolDefinition[]; + enabled: boolean; + sessionId?: string; + sessionKey?: string; + agentId?: string; + runId?: string; + catalogRef?: ToolSearchCatalogRef; +}): { tools: ToolDefinition[]; compacted: boolean; catalogToolCount: number } { + const key = sessionCatalogKey(params); + if (!params.enabled || (!key && !params.catalogRef)) { + return { tools: params.tools, compacted: false, catalogToolCount: 0 }; + } + const existing = params.catalogRef?.current ?? (key ? sessionCatalogs.get(key) : undefined); + if (!existing) { + return { tools: params.tools, compacted: false, catalogToolCount: 0 }; + } + registerToolSearchCatalog({ + ...params, + entries: params.tools.map((tool) => toCatalogEntry(tool, "client")), + append: true, + }); + return { tools: [], compacted: params.tools.length > 0, catalogToolCount: params.tools.length }; +} diff --git a/src/agents/tool-search-code-mode.ts b/src/agents/tool-search-code-mode.ts new file mode 100644 index 000000000000..bc6f74c4b4e1 --- /dev/null +++ b/src/agents/tool-search-code-mode.ts @@ -0,0 +1,261 @@ +import { spawn } from "node:child_process"; +import os from "node:os"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import type { AgentToolUpdateCallback } from "./runtime/index.js"; +import { appendBoundedTextTail, SESSION_TOOL_STDERR_TAIL_BYTES } from "./sessions/tools/limits.js"; +import { TOOL_SEARCH_CODE_MODE_CHILD_SOURCE } from "./tool-search-code-mode-child.js"; +import { toToolSearchJsonSafe } from "./tool-search-json.js"; +import { ToolSearchRuntime } from "./tool-search-runtime.js"; +import type { + CodeModeBridgeMethod, + CodeModeBridgeResultMessage, + CodeModeChildMessage, + ToolSearchConfig, + ToolSearchToolContext, +} from "./tool-search-types.js"; +import { asToolParamsRecord, ToolInputError } from "./tools/common.js"; + +export async function runCodeMode(params: { + toolCallId: string; + ctx: ToolSearchToolContext; + code: string; + config: ToolSearchConfig; + signal?: AbortSignal; + onUpdate?: AgentToolUpdateCallback; +}) { + const runtime = new ToolSearchRuntime(params.ctx, params.config); + const logs: string[] = []; + const value = await runCodeModeChild({ + code: params.code, + config: params.config, + logs, + parentToolCallId: params.toolCallId, + runtime, + signal: params.signal, + onUpdate: params.onUpdate, + }); + return { + ok: true, + value: toToolSearchJsonSafe(value), + logs, + telemetry: runtime.telemetry(), + }; +} + +function buildCodeModeChildArgs(): string[] { + if (!process.allowedNodeEnvironmentFlags.has("--permission")) { + throw new ToolInputError("tool_search_code requires a Node runtime with --permission support."); + } + return ["--permission", "--input-type=module", "--eval", TOOL_SEARCH_CODE_MODE_CHILD_SOURCE]; +} + +function isCodeModeBridgeMethod(value: unknown): value is CodeModeBridgeMethod { + return value === "search" || value === "describe" || value === "call"; +} + +async function runCodeModeBridgeRequest( + runtime: ToolSearchRuntime, + method: CodeModeBridgeMethod, + args: unknown, + options?: { + parentToolCallId?: string; + signal?: AbortSignal; + onUpdate?: AgentToolUpdateCallback; + }, +): Promise { + const values = Array.isArray(args) ? args : []; + switch (method) { + case "search": { + const query = values[0]; + if (typeof query !== "string") { + throw new ToolInputError("search query must be a string."); + } + const optionsLocal = isRecord(values[1]) ? values[1] : undefined; + return await runtime.search(query, { + limit: typeof optionsLocal?.limit === "number" ? optionsLocal.limit : undefined, + }); + } + case "describe": { + const id = values[0]; + if (typeof id !== "string") { + throw new ToolInputError("describe id must be a string."); + } + 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, + recoverySurface: "code-mode", + }); + } + } + throw new ToolInputError("Unsupported tool_search_code bridge method."); +} + +export function appendToolSearchCodeStderrTail(current: string, chunk: string): string { + return appendBoundedTextTail(current, chunk, SESSION_TOOL_STDERR_TAIL_BYTES); +} + +export function runCodeModeChild(params: { + code: string; + config: ToolSearchConfig; + logs: string[]; + parentToolCallId: string; + runtime: ToolSearchRuntime; + signal?: AbortSignal; + onUpdate?: AgentToolUpdateCallback; +}): Promise { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, buildCodeModeChildArgs(), { + cwd: os.tmpdir(), + env: {}, + // The worker returns logs/results over IPC and never writes stdout. + // Ignore it so an unused pipe cannot fill or surface unhandled errors. + stdio: ["ignore", "ignore", "pipe", "ipc"], + }); + let stderrTail = ""; + let settled = false; + let timedOut = false; + let exitRejectionTimer: ReturnType | undefined; + const bridgeAbortController = new AbortController(); + const settle = (callback: () => void) => { + if (settled) { + return; + } + settled = true; + if (timer) { + clearTimeout(timer); + } + if (exitRejectionTimer) { + clearTimeout(exitRejectionTimer); + } + params.signal?.removeEventListener("abort", abortFromParent); + child.kill(); + callback(); + }; + const abortFromParent: () => void = () => { + bridgeAbortController.abort(params.signal?.reason); + child.kill("SIGKILL"); + settle(() => reject(new Error("tool_search_code aborted"))); + }; + const timer: ReturnType | undefined = setTimeout(() => { + timedOut = true; + bridgeAbortController.abort(new Error("tool_search_code timed out")); + child.kill("SIGKILL"); + settle(() => reject(new Error("tool_search_code timed out"))); + }, params.config.codeTimeoutMs); + params.signal?.addEventListener("abort", abortFromParent, { once: true }); + if (params.signal?.aborted) { + abortFromParent(); + return; + } + + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (chunk: string) => { + stderrTail = appendToolSearchCodeStderrTail(stderrTail, chunk); + }); + child.stderr?.on("error", (error) => { + settle(() => reject(error)); + }); + child.on("error", (error) => { + settle(() => reject(error)); + }); + child.on("exit", (code, signal) => { + if (settled) { + return; + } + const rejectOnExit = () => { + const suffix = stderrTail.trim(); + const detail = suffix ? `: ${sliceUtf16Safe(suffix, -500)}` : ""; + settle(() => + reject( + new Error( + timedOut + ? "tool_search_code timed out" + : `tool_search_code child exited with ${signal ?? code}${detail}`, + ), + ), + ); + }; + if (code === 0 && signal === null) { + // A clean exit can race the final IPC result. + exitRejectionTimer = setTimeout(rejectOnExit, 250); + return; + } + rejectOnExit(); + }); + child.on("message", (message: CodeModeChildMessage) => { + if (settled || !isRecord(message) || typeof message.type !== "string") { + return; + } + if (message.type === "log") { + const items = Array.isArray(message.items) ? message.items : []; + params.logs.push(items.map((item) => String(item)).join(" ")); + return; + } + if (message.type === "result") { + if (message.ok) { + settle(() => resolve(message.value)); + } else { + settle(() => + reject(new Error(typeof message.error === "string" ? message.error : "code failed")), + ); + } + return; + } + if (message.type !== "bridge") { + return; + } + const id = typeof message.id === "string" ? message.id : ""; + const method = isCodeModeBridgeMethod(message.method) ? message.method : undefined; + if (!id || !method) { + return; + } + void runCodeModeBridgeRequest(params.runtime, method, message.args, { + parentToolCallId: params.parentToolCallId, + signal: bridgeAbortController.signal, + onUpdate: params.onUpdate, + }) + .then((value) => { + if (settled || !child.connected) { + return; + } + const response: CodeModeBridgeResultMessage = { + type: "bridge-result", + id, + ok: true, + value: toToolSearchJsonSafe(value), + }; + child.send(response, () => undefined); + }) + .catch((error: unknown) => { + if (settled || !child.connected) { + return; + } + const response: CodeModeBridgeResultMessage = { + type: "bridge-result", + id, + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + child.send(response, () => undefined); + }); + }); + + child.send({ type: "run", code: params.code, timeoutMs: params.config.codeTimeoutMs }); + }); +} + +export function readToolSearchCode(args: unknown): string { + const params = asToolParamsRecord(args); + const code = params.code; + if (typeof code !== "string" || !code.trim()) { + throw new ToolInputError("code must be a non-empty string."); + } + return code; +} diff --git a/src/agents/tool-search-config.ts b/src/agents/tool-search-config.ts new file mode 100644 index 000000000000..89d3204683d5 --- /dev/null +++ b/src/agents/tool-search-config.ts @@ -0,0 +1,79 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { ToolSearchConfig, ToolSearchMode } from "./tool-search-types.js"; + +const DEFAULT_CODE_TIMEOUT_MS = 10_000; +const DEFAULT_SEARCH_LIMIT = 8; +const DEFAULT_MAX_SEARCH_LIMIT = 20; + +function readToolSearchConfig(config?: OpenClawConfig): Record { + const tools = isRecord(config?.tools) ? config.tools : undefined; + const toolSearch = tools?.toolSearch; + if (toolSearch === true) { + return { enabled: true }; + } + if (toolSearch === false) { + return { enabled: false }; + } + return isRecord(toolSearch) ? toolSearch : {}; +} + +function readBoolean(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +function readInteger(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback; +} + +let toolSearchCodeModeSupportedForTest: boolean | undefined; +let toolSearchMinCodeTimeoutMsForTest: number | undefined; + +export function isToolSearchCodeModeSupported(): boolean { + if (toolSearchCodeModeSupportedForTest !== undefined) { + return toolSearchCodeModeSupportedForTest; + } + return process.allowedNodeEnvironmentFlags.has("--permission"); +} + +function resolveMinCodeTimeoutMs(): number { + return toolSearchMinCodeTimeoutMsForTest ?? 1000; +} + +export function resolveToolSearchConfig(config?: OpenClawConfig): ToolSearchConfig { + const raw = readToolSearchConfig(config); + const rawMode = typeof raw.mode === "string" ? raw.mode : "code"; + const requestedMode: ToolSearchMode = + rawMode === "tools" || rawMode === "directory" || rawMode === "code" ? rawMode : "code"; + const mode: ToolSearchMode = + requestedMode === "code" && !isToolSearchCodeModeSupported() ? "tools" : requestedMode; + const configured = Object.keys(raw).some((key) => key !== "enabled"); + const maxSearchLimit = Math.max( + 1, + Math.min(50, readInteger(raw.maxSearchLimit, DEFAULT_MAX_SEARCH_LIMIT)), + ); + return { + enabled: readBoolean(raw.enabled, configured), + mode, + codeTimeoutMs: Math.max( + resolveMinCodeTimeoutMs(), + Math.min(60_000, readInteger(raw.codeTimeoutMs, DEFAULT_CODE_TIMEOUT_MS)), + ), + searchDefaultLimit: Math.max( + 1, + Math.min(maxSearchLimit, readInteger(raw.searchDefaultLimit, DEFAULT_SEARCH_LIMIT)), + ), + maxSearchLimit, + }; +} + +export function setToolSearchCodeModeSupportedForTest(value: boolean | undefined): void { + toolSearchCodeModeSupportedForTest = value; +} + +export function setToolSearchMinCodeTimeoutMsForTest(value: number | undefined): void { + toolSearchMinCodeTimeoutMsForTest = + typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.floor(value) + : undefined; +} diff --git a/src/agents/tool-search-directory.ts b/src/agents/tool-search-directory.ts new file mode 100644 index 000000000000..48ea5f9116c6 --- /dev/null +++ b/src/agents/tool-search-directory.ts @@ -0,0 +1,486 @@ +import { + normalizeStringEntries, + uniqueStrings, +} from "@openclaw/normalization-core/string-normalization"; +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import { + applyToolCatalogCompaction, + classifyTool, + collectUniqueCatalogToolNames, + compactToolSearchCatalogEntry, + resolveCatalog, + visibleCatalogEntries, +} from "./tool-search-catalog.js"; +import { resolveToolSearchConfig } from "./tool-search-config.js"; +import { ToolSearchRuntime } from "./tool-search-runtime.js"; +import { + TOOL_SCHEMA_DIRECTORY_CONTROL_TOOL_NAMES, + TOOL_SEARCH_CONTROL_TOOL_NAMES, + TOOL_SEARCH_RAW_TOOL_NAME, + type CatalogVisibilityOptions, + type ToolSearchCatalogRef, + type ToolSearchToolContext, +} from "./tool-search-types.js"; +import { ToolInputError, type AnyAgentTool } from "./tools/common.js"; + +export const MAX_TOOL_SCHEMA_DIRECTORY_PROMPT_CHARS = 18_000; +const TOOL_DIRECTORY_IDENTIFIER_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/u; + +type ToolSearchDirectoryIntent = { + tokens: Set; + hasUrl: boolean; + hasFilePath: boolean; + hasMention: boolean; + hasSchedule: boolean; + hasCurrentFact: boolean; + hasMemoryRecall: boolean; +}; +type ToolDirectoryFamily = "memory" | "web"; + +export function applyToolSchemaDirectoryCatalog(params: { + tools: AnyAgentTool[]; + config?: Parameters[0]; + sessionId?: string; + sessionKey?: string; + agentId?: string; + runId?: string; + catalogRef?: ToolSearchCatalogRef; + toolHookContext?: Parameters[0]["toolHookContext"]; + hydrateToolNames?: Iterable; +}) { + const config = resolveToolSearchConfig(params.config); + if (!config.enabled) { + return { + tools: params.tools, + compacted: false, + catalogToolCount: 0, + catalogRegistered: false, + catalogReused: false, + }; + } + if (!params.tools.some((tool) => tool.name === TOOL_SEARCH_RAW_TOOL_NAME)) { + return { + tools: params.tools.filter((tool) => !TOOL_SEARCH_CONTROL_TOOL_NAMES.has(tool.name)), + compacted: false, + catalogToolCount: 0, + catalogRegistered: false, + catalogReused: false, + }; + } + const hydrateToolNames = new Set( + normalizeStringEntries(Array.from(params.hydrateToolNames ?? [])), + ); + const uniqueCatalogToolNames = collectUniqueCatalogToolNames(params.tools); + return applyToolCatalogCompaction({ + ...params, + enabled: config.enabled, + isVisibleControlTool: (tool) => TOOL_SCHEMA_DIRECTORY_CONTROL_TOOL_NAMES.has(tool.name), + isVisibleCatalogTool: (tool) => + hydrateToolNames.has(tool.name) && uniqueCatalogToolNames.has(tool.name), + }); +} + +export function buildToolSchemaDirectoryPrompt( + ctx: ToolSearchToolContext, + options?: CatalogVisibilityOptions, +): string { + const runtime = new ToolSearchRuntime( + ctx, + resolveToolSearchConfig(ctx.runtimeConfig ?? ctx.config), + ); + return formatToolSearchCatalogDirectory(runtime.all(options)); +} + +export function resolveToolSearchCatalogTool( + ctx: ToolSearchToolContext, + name: unknown, + options?: CatalogVisibilityOptions, +): AnyAgentTool | undefined { + if (typeof name !== "string") { + return undefined; + } + const needle = name.trim(); + if (!needle) { + return undefined; + } + try { + const matches = visibleCatalogEntries(resolveCatalog(ctx), options).filter( + (entry) => entry.name === needle, + ); + return matches.length === 1 ? (matches[0]?.tool as AnyAgentTool | undefined) : undefined; + } catch (error) { + if (error instanceof ToolInputError) { + return undefined; + } + throw error; + } +} + +function compactDirectoryDescription(description: string): string { + const normalized = description.replace(/\s+/g, " ").trim(); + if (normalized.length <= 180) { + return normalized; + } + return `${truncateUtf16Safe(normalized, 177).trimEnd()}...`; +} + +function formatToolDirectoryIdentifier(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed && TOOL_DIRECTORY_IDENTIFIER_RE.test(trimmed) ? trimmed : undefined; +} + +function formatToolDirectoryEntry( + entry: ReturnType, +): string | undefined { + if (entry.source !== "openclaw") { + return undefined; + } + const name = formatToolDirectoryIdentifier(entry.name); + if (!name) { + return undefined; + } + const description = compactDirectoryDescription(entry.description); + const ownerName = formatToolDirectoryIdentifier(entry.sourceName); + const owner = ownerName ? ` (${ownerName})` : ""; + return `- ${name}${owner}: ${description || "No description."}`; +} + +function renderToolSearchCatalogDirectory(lines: string[], total: number): string { + const omitted = total - lines.length; + const footer = + omitted > 0 + ? `${omitted} additional tools omitted. Use tool_search to find them, then tool_describe to load a full schema before tool_call.` + : "Call tool_describe with a listed tool name to load its full schema before using tool_call."; + return ["Available deferred-schema tools:", ...lines, "", footer].join("\n"); +} + +function formatToolSearchCatalogDirectory( + entries: Array>, +): string { + if (entries.length === 0) { + return "Available deferred-schema tools: none."; + } + const nameCounts = new Map(); + for (const entry of entries) { + nameCounts.set(entry.name, (nameCounts.get(entry.name) ?? 0) + 1); + } + const lines = entries + .filter((entry) => nameCounts.get(entry.name) === 1) + .toSorted((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)) + .map(formatToolDirectoryEntry) + .filter((line): line is string => Boolean(line)); + const fullDirectory = renderToolSearchCatalogDirectory(lines, entries.length); + if (fullDirectory.length <= MAX_TOOL_SCHEMA_DIRECTORY_PROMPT_CHARS) { + return fullDirectory; + } + let low = 0; + let high = lines.length; + while (low < high) { + const middle = Math.ceil((low + high) / 2); + if ( + renderToolSearchCatalogDirectory(lines.slice(0, middle), entries.length).length <= + MAX_TOOL_SCHEMA_DIRECTORY_PROMPT_CHARS + ) { + low = middle; + } else { + high = middle - 1; + } + } + return renderToolSearchCatalogDirectory(lines.slice(0, low), entries.length); +} + +const TOOL_DIRECTORY_HYDRATION_KEYWORDS: Array<{ + terms: readonly string[]; + toolHints: readonly string[]; + weight: number; +}> = [ + { + terms: ["search", "lookup", "look", "find", "current", "today", "price", "latest", "news"], + toolHints: ["searxng", "web"], + weight: 8, + }, + { + terms: ["url", "link", "page", "fetch", "read", "article", "http", "https"], + toolHints: ["fetch", "browser"], + weight: 8, + }, + { + terms: ["send", "reply", "message", "post", "react", "embed", "discord", "imessage"], + toolHints: ["message", "session", "send"], + weight: 7, + }, + { + terms: ["file", "path", "read", "write", "edit", "patch", "grep", "list"], + toolHints: ["read", "write", "edit", "grep", "find", "ls", "patch"], + weight: 6, + }, + { + terms: ["run", "command", "shell", "terminal", "build", "test", "pnpm", "git"], + toolHints: ["exec", "process"], + weight: 7, + }, + { + terms: [ + "remember", + "recall", + "memory", + "memories", + "known", + "history", + "previous", + "prior", + "earlier", + "decided", + "decision", + "discussed", + ], + toolHints: ["memory"], + weight: 6, + }, + { + terms: ["remind", "schedule", "later", "tomorrow", "daily", "weekly", "cron"], + toolHints: ["cron", "automation", "heartbeat"], + weight: 8, + }, + { + terms: ["image", "picture", "photo", "meme", "gif", "screenshot", "visual"], + toolHints: ["image", "vision", "browser"], + weight: 6, + }, + { + terms: ["audio", "voice", "speak", "tts", "transcribe"], + toolHints: ["audio", "voice", "tts"], + weight: 6, + }, +]; + +function tokenize(input: string): string[] { + return normalizeStringEntries(input.toLowerCase().split(/[^a-z0-9_./:-]+/u)); +} + +function readToolDirectoryIntent(query: string): ToolSearchDirectoryIntent { + const tokens = new Set(tokenize(query)); + const hasCurrentFact = ["current", "today", "latest", "price", "weather", "news"].some((term) => + tokens.has(term), + ); + const hasExplicitMemoryRecall = [ + "remember", + "recall", + "memory", + "memories", + "known", + "history", + "previous", + "prior", + "earlier", + "decided", + "decision", + "discussed", + ].some((term) => tokens.has(term)); + const hasIdentityRecall = + /\b(?:do you know|who (?:is|are|was)|what did (?:we|i|you|they)|when did (?:we|i|you|they))\b/iu.test( + query, + ); + return { + tokens, + hasUrl: tokens.has("http") || tokens.has("https") || /https?:\/\//iu.test(query), + hasFilePath: tokens.has("/") || /(^|\s)(\.{1,2}\/|\/|[a-z]:\\)/iu.test(query), + hasMention: /<@!?\d+>/u.test(query) || tokens.has("discord"), + hasSchedule: ["remind", "schedule", "later", "tomorrow", "daily", "weekly", "cron"].some( + (term) => tokens.has(term), + ), + hasCurrentFact, + hasMemoryRecall: hasExplicitMemoryRecall || (hasIdentityRecall && !hasCurrentFact), + }; +} + +function classifyDirectoryToolFamilies( + tool: Pick, + intent: ToolSearchDirectoryIntent, +): Set { + const toolText = `${tool.name} ${tool.description ?? ""}`.toLowerCase(); + const families = new Set(); + if (TOOL_SEARCH_CONTROL_TOOL_NAMES.has(tool.name)) { + return families; + } + const hasMemoryToolSignal = + /\b(?:memory|memories|recall|remember|history|prior|knowledge|libravdb)\b/iu.test(toolText) || + /(?:^|_)(?:memory|recall|remember|libravdb)(?:_|$)/iu.test(tool.name); + const hasWebToolSignal = + /\b(?:web|internet|online|browser|url|http|https|page|article|fetch|crawl|searxng|google|bing|brave|tavily|duckduckgo|serp)\b/iu.test( + toolText, + ) || + /(?:^|_)(?:web|fetch|browser|searxng|google|bing|brave|tavily|duckduckgo|serp)(?:_|$)/iu.test( + tool.name, + ); + const hasWebIntent = + intent.hasUrl || + intent.hasCurrentFact || + ["search", "lookup", "look", "find", "current", "today", "price", "latest", "news"].some( + (term) => intent.tokens.has(term), + ); + if (hasWebToolSignal && hasWebIntent) { + families.add("web"); + } + if (hasMemoryToolSignal && intent.hasMemoryRecall) { + families.add("memory"); + } + return families; +} + +function scoreDirectoryTool( + tool: Pick, + intent: ToolSearchDirectoryIntent, +) { + const toolText = `${tool.name} ${tool.description ?? ""}`.toLowerCase(); + const toolTokens = new Set(tokenize(toolText)); + let score = 0; + for (const token of toolTokens) { + if (intent.tokens.has(token)) { + score += 2; + } + } + for (const group of TOOL_DIRECTORY_HYDRATION_KEYWORDS) { + if ( + group.terms.some((term) => intent.tokens.has(term)) && + group.toolHints.some((hint) => toolText.includes(hint)) + ) { + score += group.weight; + } + } + if (intent.hasUrl && /fetch|browser|web/iu.test(toolText)) { + score += 10; + } + if (intent.hasFilePath && /read|write|edit|grep|find|ls|file|patch/iu.test(toolText)) { + score += 8; + } + if (intent.hasMention && /message|discord|react|send/iu.test(toolText)) { + score += 8; + } + if (intent.hasSchedule && /cron|schedule|remind|heartbeat|automation/iu.test(toolText)) { + score += 8; + } + if ( + intent.hasCurrentFact && + /searxng|web|internet|online|fetch|weather|finance|price|google|bing|brave|tavily|duckduckgo|serp/iu.test( + toolText, + ) + ) { + score += 8; + } + if ( + intent.hasMemoryRecall && + /memory|memories|recall|remember|history|prior|knowledge|libravdb/iu.test(toolText) + ) { + score += 8; + } + return score; +} + +function expandDirectoryHydrationGroups(params: { + selectedNames: readonly string[]; + tools: readonly Pick[]; + intent: ToolSearchDirectoryIntent; + maxTools: number; +}): string[] { + if (params.maxTools <= 0) { + return []; + } + const emitted = new Set(); + const expandedFamilies = new Set(); + const expanded: string[] = []; + const toolsByName = new Map(params.tools.map((tool) => [tool.name, tool])); + const toolsByFamily = new Map(); + const selectedRank = new Map(params.selectedNames.map((name, index) => [name, index])); + for (const tool of params.tools) { + for (const family of classifyDirectoryToolFamilies(tool, params.intent)) { + const names = toolsByFamily.get(family) ?? []; + names.push(tool.name); + toolsByFamily.set(family, names); + } + } + for (const names of toolsByFamily.values()) { + names.sort( + (a, b) => + (selectedRank.get(a) ?? Number.MAX_SAFE_INTEGER) - + (selectedRank.get(b) ?? Number.MAX_SAFE_INTEGER) || a.localeCompare(b), + ); + } + for (const selectedName of params.selectedNames) { + if (expanded.length >= params.maxTools) { + break; + } + if (!emitted.has(selectedName)) { + expanded.push(selectedName); + emitted.add(selectedName); + } + const selectedTool = toolsByName.get(selectedName); + if (!selectedTool || expanded.length >= params.maxTools) { + continue; + } + for (const family of classifyDirectoryToolFamilies(selectedTool, params.intent)) { + if (expandedFamilies.has(family)) { + continue; + } + expandedFamilies.add(family); + for (const groupedName of toolsByFamily.get(family) ?? []) { + if (expanded.length >= params.maxTools) { + return expanded; + } + if (!emitted.has(groupedName)) { + expanded.push(groupedName); + emitted.add(groupedName); + } + } + } + } + return expanded; +} + +export function estimateToolSchemaDirectoryToolNames(params: { + tools: readonly AnyAgentTool[]; + query?: string; + maxTools?: number; + requiredToolNames?: Iterable; +}): string[] { + const maxTools = Math.max(0, Math.min(12, params.maxTools ?? 4)); + const hydratableTools: AnyAgentTool[] = []; + const externalToolNames = new Set(); + const uniqueCatalogToolNames = collectUniqueCatalogToolNames(params.tools); + for (const tool of params.tools) { + if (!uniqueCatalogToolNames.has(tool.name)) { + continue; + } + if (classifyTool(tool).source === "mcp") { + externalToolNames.add(tool.name); + continue; + } + hydratableTools.push(tool); + } + const required = normalizeStringEntries(Array.from(params.requiredToolNames ?? [])).filter( + (name) => !externalToolNames.has(name), + ); + const requiredSet = new Set(required); + const query = params.query?.trim() ?? ""; + if (!query && required.length >= maxTools) { + return required.slice(0, maxTools); + } + const intent = readToolDirectoryIntent(query); + const scored = hydratableTools + .filter((tool) => !TOOL_SEARCH_CONTROL_TOOL_NAMES.has(tool.name)) + .map((tool) => ({ + name: tool.name, + score: requiredSet.has(tool.name) + ? Number.MAX_SAFE_INTEGER + : scoreDirectoryTool(tool, intent), + })) + .filter((entry) => entry.score > 0) + .toSorted((a, b) => b.score - a.score || a.name.localeCompare(b.name)); + const selected = uniqueStrings([...required, ...scored.map((entry) => entry.name)]); + return expandDirectoryHydrationGroups({ + selectedNames: selected, + tools: hydratableTools, + intent, + maxTools, + }); +} diff --git a/src/agents/tool-search-json.ts b/src/agents/tool-search-json.ts new file mode 100644 index 000000000000..b13adaa6fe8d --- /dev/null +++ b/src/agents/tool-search-json.ts @@ -0,0 +1,29 @@ +/** Convert bridge and transcript values into detached JSON-compatible data. */ +export function toToolSearchJsonSafe(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 value.message; + } + if (value === null) { + return null; + } + switch (typeof value) { + case "string": + return value; + case "number": + case "boolean": + case "bigint": + case "symbol": + case "function": + return String(value); + default: + return Object.prototype.toString.call(value); + } + } +} diff --git a/src/agents/tool-search-runtime.ts b/src/agents/tool-search-runtime.ts new file mode 100644 index 000000000000..bb9f55d76119 --- /dev/null +++ b/src/agents/tool-search-runtime.ts @@ -0,0 +1,429 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { + normalizeStringEntries, + uniqueStrings, +} from "@openclaw/normalization-core/string-normalization"; +import { getPluginToolMeta } from "../plugins/tools.js"; +import { isPreExecutionBlockedToolResult } from "./agent-tools.before-tool-call.js"; +import { getChannelAgentToolMeta } from "./channel-tool-metadata.js"; +import type { AgentToolResult } from "./runtime/index.js"; +import { isAgentToolReplaySafe } from "./tool-replay-safety.js"; +import { + compactToolSearchCatalogEntry, + resolveCatalog, + visibleCatalogEntries, +} from "./tool-search-catalog.js"; +import { snapshotToolSearchTargetTranscriptResult } from "./tool-search-transcript.js"; +import type { + CatalogSource, + CatalogVisibilityOptions, + ToolSearchCallOptions, + ToolSearchCatalogEntry, + ToolSearchCatalogSession, + ToolSearchCatalogToolExecutor, + ToolSearchConfig, + ToolSearchToolContext, + UnknownToolErrorOptions, + UnknownToolRecoverySurface, +} from "./tool-search-types.js"; +import { asToolParamsRecord, ToolInputError } from "./tools/common.js"; + +function describeEntry(entry: ToolSearchCatalogEntry) { + return { + ...compactToolSearchCatalogEntry(entry), + parameters: entry.parameters ?? {}, + ...(entry.outputSchema ? { outputSchema: entry.outputSchema } : {}), + }; +} + +function tokenize(input: string): string[] { + return normalizeStringEntries(input.toLowerCase().split(/[^a-z0-9_./:-]+/u)); +} + +function scoreEntry(entry: ToolSearchCatalogEntry, terms: string[]): number { + if (terms.length === 0) { + return 1; + } + const name = entry.name.toLowerCase(); + const id = entry.id.toLowerCase(); + const label = (entry.label ?? "").toLowerCase(); + const description = entry.description.toLowerCase(); + let score = 0; + for (const term of terms) { + if (name === term || id === term) { + score += 20; + } + if (name.includes(term)) { + score += 8; + } + if (id.includes(term)) { + score += 6; + } + if (label.includes(term)) { + score += 4; + } + if (description.includes(term)) { + score += 2; + } + } + return score; +} + +function tokenizeLookupValue(input: string): Set { + 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(); + 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); + const exactIdEntry = entries.find((candidate) => candidate.id === needle); + if (exactIdEntry) { + return exactIdEntry; + } + const namedEntries = entries.filter((candidate) => candidate.name === needle); + if (namedEntries.length > 1) { + throw new ToolInputError(`Ambiguous tool name: ${needle}; use an exact tool id.`); + } + const namedEntry = namedEntries[0]; + if (!namedEntry) { + throw new ToolInputError(formatUnknownToolIdError(needle, entries, errorOptions)); + } + return namedEntry; +} + +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( + formatUnknownToolIdError(needle, catalog.entries, { ...errorOptions, exactIdOnly: true }), + ); + } + return entry; +} + +export function readToolSearchId(args: unknown): string { + const params = asToolParamsRecord(args); + const value = params.id ?? params.toolId ?? params.name; + if (typeof value !== "string" || !value.trim()) { + throw new ToolInputError("id must be a non-empty string."); + } + return value.trim(); +} + +function readToolSearchLimit(value: unknown, config: ToolSearchConfig): number { + if (value === undefined) { + return config.searchDefaultLimit; + } + if (typeof value !== "number" || !Number.isInteger(value) || value < 1) { + throw new ToolInputError("limit must be a positive integer."); + } + return Math.min(value, config.maxSearchLimit); +} + +export function readToolSearchArgs( + args: unknown, + config: ToolSearchConfig, +): { query: string; limit: number } { + const params = asToolParamsRecord(args); + const query = params.query; + if (typeof query !== "string") { + throw new ToolInputError("query must be a string."); + } + const options = isRecord(params.options) ? params.options : undefined; + return { + query, + limit: readToolSearchLimit(params.limit ?? options?.limit, config), + }; +} + +export function readToolSearchCallArgs(args: unknown): { id: string; input: unknown } { + const params = asToolParamsRecord(args); + return { + id: readToolSearchId(params), + input: params.args ?? params.input ?? {}, + }; +} + +function getTelemetry(catalog: ToolSearchCatalogSession) { + const sources: Record = { openclaw: 0, mcp: 0, client: 0 }; + for (const entry of catalog.entries) { + sources[entry.source] += 1; + } + return { + catalogSize: catalog.entries.length, + sources, + searchCount: catalog.searchCount, + describeCount: catalog.describeCount, + callCount: catalog.callCount, + }; +} + +let schemaValidatorModulePromise: + | Promise + | undefined; + +async function validateCatalogOutputValue( + entry: ToolSearchCatalogEntry, + value: unknown, +): Promise< + ReturnType | undefined +> { + if (!entry.outputSchema) { + return undefined; + } + try { + schemaValidatorModulePromise ??= import("../plugins/schema-validator.js"); + const { validateJsonSchemaValue } = await schemaValidatorModulePromise; + return validateJsonSchemaValue({ + schema: entry.outputSchema as never, + cacheKey: `tool-output:${entry.id}`, + value, + }); + } catch (error) { + throw new Error(`Tool "${entry.id}" has an invalid outputSchema.`, { cause: error }); + } +} + +async function assertCatalogOutputSchemaIsValid(entry: ToolSearchCatalogEntry): Promise { + // Compile before execution so a bad contract cannot follow a successful side effect. + await validateCatalogOutputValue(entry, undefined); +} + +async function assertCatalogOutputMatchesSchema( + entry: ToolSearchCatalogEntry, + result: AgentToolResult, +): Promise { + if (!entry.outputSchema) { + return; + } + if (isPreExecutionBlockedToolResult(result)) { + const details = unwrapToolResultValue(result); + const reason = + isRecord(details) && typeof details.reason === "string" && details.reason.trim() + ? details.reason + : "Tool call blocked by policy"; + throw new Error(`Tool "${entry.id}" was blocked before execution: ${reason}`); + } + const validation = await validateCatalogOutputValue(entry, unwrapToolResultValue(result)); + if (!validation || validation.ok) { + return; + } + throw new Error( + `Tool "${entry.id}" returned details that do not match its declared outputSchema.`, + ); +} + +function sanitizeToolCallIdPart(value: string): string { + const trimmed = value.trim(); + const safe = trimmed.replace(/[^A-Za-z0-9_.:-]+/g, "_").slice(0, 120); + return safe || "call"; +} + +export class ToolSearchRuntime { + private callSequence = 0; + + constructor( + private readonly ctx: ToolSearchToolContext, + private readonly config: ToolSearchConfig, + ) {} + + search = async (query: string, options?: { limit?: number } & CatalogVisibilityOptions) => { + const catalog = resolveCatalog(this.ctx); + catalog.searchCount += 1; + const limit = readToolSearchLimit(options?.limit, this.config); + const terms = tokenize(query); + return visibleCatalogEntries(catalog, options) + .map((entry) => ({ entry, score: scoreEntry(entry, terms) })) + .filter((hit) => hit.score > 0) + .toSorted((a, b) => b.score - a.score || a.entry.id.localeCompare(b.entry.id)) + .slice(0, limit) + .map((hit) => compactToolSearchCatalogEntry(hit.entry)); + }; + + all = (options?: CatalogVisibilityOptions) => + visibleCatalogEntries(resolveCatalog(this.ctx), options).map((entry) => + compactToolSearchCatalogEntry(entry), + ); + + namespaceEntries = () => + resolveCatalog(this.ctx).entries.map((entry) => + Object.assign(compactToolSearchCatalogEntry(entry), { + parameters: entry.parameters ?? {}, + }), + ); + + describe = async (id: string, options?: CatalogVisibilityOptions & UnknownToolErrorOptions) => { + const catalog = resolveCatalog(this.ctx); + catalog.describeCount += 1; + return describeEntry(findEntry(catalog, id, options, options)); + }; + + call = async (id: string, input?: unknown, options?: ToolSearchCallOptions) => { + const catalog = resolveCatalog(this.ctx); + return await this.callEntry(catalog, findEntry(catalog, id, options, options), input, options); + }; + + callExactId = async ( + id: string, + input?: unknown, + options?: { + parentToolCallId?: string; + signal?: AbortSignal; + onUpdate?: ToolSearchCallOptions["onUpdate"]; + recoverySurface?: UnknownToolRecoverySurface; + }, + ) => { + const catalog = resolveCatalog(this.ctx); + return await this.callEntry(catalog, findEntryByExactId(catalog, id, options), input, options); + }; + + callValue = async (id: string, input?: unknown, options?: ToolSearchCallOptions) => + unwrapToolResultValue((await this.call(id, input, options)).result); + + isReplaySafeExactId = (id: string): boolean => { + let entry: ToolSearchCatalogEntry; + try { + entry = findEntryByExactId(resolveCatalog(this.ctx), id); + } catch { + return false; + } + if (entry.source !== "openclaw") { + return false; + } + const pluginMeta = getPluginToolMeta(entry.tool as Parameters[0]); + if (pluginMeta) { + return pluginMeta.mcp ? false : pluginMeta.replaySafe === true; + } + if (getChannelAgentToolMeta(entry.tool as never)) { + return false; + } + return isAgentToolReplaySafe(entry.tool); + }; + + private readonly callEntry = async ( + catalog: ToolSearchCatalogSession, + entry: ToolSearchCatalogEntry, + input?: unknown, + options?: { + parentToolCallId?: string; + signal?: AbortSignal; + onUpdate?: ToolSearchCallOptions["onUpdate"]; + }, + ) => { + catalog.callCount += 1; + await assertCatalogOutputSchemaIsValid(entry); + const parentId = sanitizeToolCallIdPart(options?.parentToolCallId ?? "direct"); + const toolCallId = `tool_search_code:${parentId}:${entry.name}:${++this.callSequence}`; + const executeTool = + this.ctx.executeTool ?? + (async (params: Parameters[0]) => { + const result = await params.tool.execute( + params.toolCallId, + params.input, + params.signal, + params.onUpdate, + undefined as never, + ); + return await params.acceptResultBeforeProjection(result); + }); + const acceptResultBeforeProjection = async (candidate: AgentToolResult) => { + if (isPreExecutionBlockedToolResult(candidate)) { + await assertCatalogOutputMatchesSchema(entry, candidate); + } + const snapshot = snapshotToolSearchTargetTranscriptResult(candidate); + await assertCatalogOutputMatchesSchema(entry, snapshot); + return snapshot; + }; + const result = await executeTool({ + tool: entry.tool, + toolName: entry.name, + source: entry.source, + sourceName: entry.sourceName, + toolCallId, + parentToolCallId: options?.parentToolCallId, + input: input ?? {}, + signal: options?.signal ?? this.ctx.abortSignal, + onUpdate: options?.onUpdate, + acceptResultBeforeProjection, + }); + const acceptedResult = await acceptResultBeforeProjection(result); + return { tool: compactToolSearchCatalogEntry(entry), result: acceptedResult }; + }; + + telemetry() { + return getTelemetry(resolveCatalog(this.ctx)); + } +} + +function unwrapToolResultValue(result: AgentToolResult): unknown { + return isRecord(result) && "details" in result ? result.details : result; +} diff --git a/src/agents/tool-search-transcript.ts b/src/agents/tool-search-transcript.ts new file mode 100644 index 000000000000..0202a5d18001 --- /dev/null +++ b/src/agents/tool-search-transcript.ts @@ -0,0 +1,172 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import type { AgentMessage, AgentToolResult } from "./runtime/index.js"; +import { toToolSearchJsonSafe } from "./tool-search-json.js"; +import type { ToolSearchTargetTranscriptProjection } from "./tool-search-types.js"; + +function readMessageToolResultId(message: AgentMessage): string | undefined { + const record = message as unknown as Record; + const role = typeof record.role === "string" ? record.role : ""; + const canUseDirectId = role === "toolResult" || role === "tool"; + const direct = record.toolCallId ?? record.toolUseId ?? record.tool_use_id; + if (canUseDirectId && typeof direct === "string" && direct.trim()) { + return direct; + } + const content = record.content; + if (!Array.isArray(content)) { + return undefined; + } + for (const block of content) { + if (!isRecord(block) || block.type !== "toolResult") { + continue; + } + const nested = block.toolCallId ?? block.toolUseId ?? block.tool_use_id ?? block.id; + if (typeof nested === "string" && nested.trim()) { + return nested; + } + } + return undefined; +} + +function textFromToolSearchProjectionResult(result: unknown, isError: boolean): string { + if (isRecord(result)) { + const details = isRecord(result.details) ? result.details : undefined; + const detailError = details?.error; + if (typeof detailError === "string" && detailError.trim()) { + return detailError; + } + const content = result.content; + if (Array.isArray(content)) { + const text = content + .map((item) => (isRecord(item) && typeof item.text === "string" ? item.text : "")) + .filter(Boolean) + .join("\n"); + if (text.trim()) { + return text; + } + } + } + const safe = toToolSearchJsonSafe(result); + if (typeof safe === "string") { + return safe; + } + const encoded = JSON.stringify(safe); + if (typeof encoded === "string") { + return encoded; + } + return isError ? "Tool Search target tool failed." : "Tool Search target tool completed."; +} + +function buildToolSearchTargetTranscriptMessages( + projection: ToolSearchTargetTranscriptProjection, +): AgentMessage[] { + const input = toToolSearchJsonSafe(projection.input); + const timestamp = projection.timestamp ?? Date.now(); + const resultRecord = isRecord(projection.result) ? projection.result : undefined; + const resultContent = + Array.isArray(resultRecord?.content) && resultRecord.content.length > 0 + ? toToolSearchJsonSafe(resultRecord.content) + : [ + { + type: "text", + text: textFromToolSearchProjectionResult( + projection.result, + projection.isError === true, + ), + }, + ]; + return [ + { + role: "assistant", + content: [ + { + type: "toolCall", + id: projection.toolCallId, + name: projection.toolName, + arguments: input, + input, + }, + ], + stopReason: "toolUse", + timestamp, + } as unknown as AgentMessage, + { + role: "toolResult", + toolCallId: projection.toolCallId, + toolName: projection.toolName, + isError: projection.isError === true, + content: resultContent, + timestamp, + } as unknown as AgentMessage, + ]; +} + +export function projectToolSearchTargetTranscriptMessages( + messages: AgentMessage[], + projections: readonly ToolSearchTargetTranscriptProjection[], +): AgentMessage[] { + if (projections.length === 0) { + return messages; + } + const byParent = new Map(); + const unmatched: ToolSearchTargetTranscriptProjection[] = []; + for (const projection of projections) { + const parent = projection.parentToolCallId?.trim(); + if (!parent) { + unmatched.push(projection); + continue; + } + const group = byParent.get(parent) ?? []; + group.push(projection); + byParent.set(parent, group); + } + const inserted = new Set(); + const projected: AgentMessage[] = []; + for (const message of messages) { + projected.push(message); + const toolResultId = readMessageToolResultId(message); + const group = toolResultId ? byParent.get(toolResultId) : undefined; + if (!group) { + continue; + } + for (const projection of group) { + projected.push(...buildToolSearchTargetTranscriptMessages(projection)); + inserted.add(projection); + } + } + for (const projection of [...unmatched, ...projections]) { + if (inserted.has(projection)) { + continue; + } + projected.push(...buildToolSearchTargetTranscriptMessages(projection)); + inserted.add(projection); + } + return projected; +} + +function freezeJsonSnapshot(value: unknown): unknown { + if (value === null || typeof value !== "object") { + return value; + } + for (const nested of Object.values(value)) { + freezeJsonSnapshot(nested); + } + return Object.freeze(value); +} + +/** Capture a stable JSON-safe result before delayed transcript settlement. */ +export function snapshotToolSearchTargetTranscriptResult( + result: AgentToolResult, +): AgentToolResult { + const hasDetails = "details" in result; + const snapshot = toToolSearchJsonSafe(result); + if (!isRecord(snapshot)) { + throw new Error("Tool Search target result could not be captured for transcript projection."); + } + if (hasDetails && !("details" in snapshot)) { + // `details` presence selects callValue unwrapping. JSON serialization drops + // an explicit undefined, so restore that marker before freezing the envelope. + snapshot.details = + result.details === undefined ? undefined : toToolSearchJsonSafe(result.details); + } + return freezeJsonSnapshot(snapshot) as AgentToolResult; +} diff --git a/src/agents/tool-search-types.ts b/src/agents/tool-search-types.ts new file mode 100644 index 000000000000..26830e9ad9de --- /dev/null +++ b/src/agents/tool-search-types.ts @@ -0,0 +1,153 @@ +import type { Result } from "@openclaw/normalization-core/result"; +import type { TSchema } from "typebox"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { PluginToolMcpMeta } from "../plugins/tools.js"; +import type { HookContext } from "./agent-tools.before-tool-call.js"; +import type { AgentToolResult, AgentToolUpdateCallback } from "./runtime/index.js"; +import type { ToolDefinition } from "./sessions/index.js"; +import type { AnyAgentTool } from "./tools/common.js"; + +export const TOOL_SEARCH_CODE_MODE_TOOL_NAME = "tool_search_code"; +export const TOOL_SEARCH_RAW_TOOL_NAME = "tool_search"; +export const TOOL_DESCRIBE_RAW_TOOL_NAME = "tool_describe"; +export const TOOL_CALL_RAW_TOOL_NAME = "tool_call"; + +export const TOOL_SEARCH_CONTROL_TOOL_NAMES = new Set([ + TOOL_SEARCH_CODE_MODE_TOOL_NAME, + TOOL_SEARCH_RAW_TOOL_NAME, + TOOL_DESCRIBE_RAW_TOOL_NAME, + TOOL_CALL_RAW_TOOL_NAME, +]); + +export const TOOL_SCHEMA_DIRECTORY_CONTROL_TOOL_NAMES = new Set([ + TOOL_SEARCH_RAW_TOOL_NAME, + TOOL_DESCRIBE_RAW_TOOL_NAME, + TOOL_CALL_RAW_TOOL_NAME, +]); + +export type ToolSearchMode = "code" | "tools" | "directory"; +export type CatalogSource = "openclaw" | "mcp" | "client"; +export type CatalogTool = AnyAgentTool | ToolDefinition; +export type CatalogVisibilityOptions = { + includeMcp?: boolean; +}; +export type UnknownToolRecoverySurface = "raw-tools" | "code-mode" | "tools"; +export type UnknownToolErrorOptions = { + exactIdOnly?: boolean; + recoverySurface?: UnknownToolRecoverySurface; +}; +export type ToolSearchCallOptions = CatalogVisibilityOptions & + UnknownToolErrorOptions & { + parentToolCallId?: string; + signal?: AbortSignal; + onUpdate?: AgentToolUpdateCallback; + }; + +export type ToolSearchCatalogToolExecutor = (params: { + tool: CatalogTool; + toolName: string; + source: CatalogSource; + sourceName?: string; + toolCallId: string; + parentToolCallId?: string; + input: unknown; + signal?: AbortSignal; + onUpdate?: AgentToolUpdateCallback; + acceptResultBeforeProjection: ( + result: AgentToolResult, + ) => Promise>; +}) => Promise>; + +/** Transcript projection for target tool calls made through Tool Search. */ +export type ToolSearchTargetTranscriptProjection = { + parentToolCallId?: string; + toolCallId: string; + toolName: string; + input: unknown; + result?: unknown; + isError?: boolean; + timestamp?: number; +}; + +/** Resolved Tool Search config after defaults, limits, and runtime support checks. */ +export type ToolSearchConfig = { + enabled: boolean; + mode: ToolSearchMode; + codeTimeoutMs: number; + searchDefaultLimit: number; + maxSearchLimit: number; +}; + +/** Per-run/session context used by Tool Search control tools. */ +export type ToolSearchToolContext = { + config?: OpenClawConfig; + runtimeConfig?: OpenClawConfig; + agentId?: string; + sessionKey?: string; + sessionId?: string; + runId?: string; + catalogRef?: ToolSearchCatalogRef; + abortSignal?: AbortSignal; + executeTool?: ToolSearchCatalogToolExecutor; + forceRestartSafeTools?: boolean; +}; + +/** Catalog entry retained behind compacted Tool Search control tools. */ +export type ToolSearchCatalogEntry = { + id: string; + source: CatalogSource; + sourceName?: string; + mcp?: PluginToolMcpMeta; + name: string; + label?: string; + description: string; + parameters?: unknown; + outputSchema?: TSchema; + tool: CatalogTool; +}; + +export type ToolSearchCatalogSession = { + entries: ToolSearchCatalogEntry[]; + searchCount: number; + describeCount: number; + callCount: number; +}; + +export type ToolSearchCatalogRef = { + current?: ToolSearchCatalogSession; +}; + +export type CodeModeBridgeMethod = "search" | "describe" | "call"; + +export type CodeModeChildMessage = + | { type: "result"; ok: true; value: unknown } + | { type: "result"; ok: false; error?: string } + | { type: "log"; items?: unknown[] } + | { type: "bridge"; id?: unknown; method?: unknown; args?: unknown }; + +export type CodeModeBridgeResultMessage = { type: "bridge-result"; id: string } & Result< + unknown, + string +>; + +export type ToolSearchCatalogApplyResult = { + tools: AnyAgentTool[]; + compacted: boolean; + catalogToolCount: number; + catalogRegistered: boolean; + catalogReused: boolean; +}; + +export type ToolSearchCatalogCompactionParams = { + tools: AnyAgentTool[]; + enabled: boolean; + sessionId?: string; + sessionKey?: string; + agentId?: string; + runId?: string; + catalogRef?: ToolSearchCatalogRef; + toolHookContext?: HookContext; + isVisibleControlTool: (tool: AnyAgentTool) => boolean; + isVisibleCatalogTool?: (tool: AnyAgentTool) => boolean; + shouldCatalogTool?: (tool: AnyAgentTool) => boolean; +}; diff --git a/src/agents/tool-search.ts b/src/agents/tool-search.ts index 399eb5ea521c..2794f61bf9f0 100644 --- a/src/agents/tool-search.ts +++ b/src/agents/tool-search.ts @@ -1,543 +1,78 @@ /** Tool Search catalog compaction for large OpenClaw, MCP, and client tool inventories. */ -import { spawn } from "node:child_process"; -import os from "node:os"; -import { isRecord } from "@openclaw/normalization-core/record-coerce"; -import type { Result } from "@openclaw/normalization-core/result"; -import { - normalizeStringEntries, - uniqueStrings, - uniqueValues, -} from "@openclaw/normalization-core/string-normalization"; -import { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; -import { Type, type TSchema } from "typebox"; +import { Type } from "typebox"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { getPluginToolMeta, type PluginToolMcpMeta } from "../plugins/tools.js"; -import { - isPreExecutionBlockedToolResult, - isToolWrappedWithBeforeToolCallHook, - rewrapToolWithBeforeToolCallHook, - type HookContext, - wrapToolWithBeforeToolCallHook, -} from "./agent-tools.before-tool-call.js"; -import { getChannelAgentToolMeta } from "./channel-tool-metadata.js"; -import type { AgentMessage, AgentToolResult, AgentToolUpdateCallback } from "./runtime/index.js"; +import type { HookContext } from "./agent-tools.before-tool-call.js"; +import type { AgentToolResult, AgentToolUpdateCallback } from "./runtime/index.js"; import type { ToolDefinition } from "./sessions/index.js"; -import { appendBoundedTextTail, SESSION_TOOL_STDERR_TAIL_BYTES } from "./sessions/tools/limits.js"; -import { isAgentToolReplaySafe } from "./tool-replay-safety.js"; -import { compactToolInputHint, compactToolOutputHint } from "./tool-schema-hints.js"; -import { TOOL_SEARCH_CODE_MODE_CHILD_SOURCE } from "./tool-search-code-mode-child.js"; -import { asToolParamsRecord, jsonResult, ToolInputError } from "./tools/common.js"; -import type { AnyAgentTool } from "./tools/common.js"; +import { + addClientToolsToToolCatalog, + applyToolCatalogCompaction, + reusableCatalogSnapshots, + sessionCatalogs, +} from "./tool-search-catalog.js"; +import { + appendToolSearchCodeStderrTail, + readToolSearchCode, + runCodeMode, + runCodeModeChild, +} from "./tool-search-code-mode.js"; +import { + isToolSearchCodeModeSupported, + resolveToolSearchConfig, + setToolSearchCodeModeSupportedForTest, + setToolSearchMinCodeTimeoutMsForTest, +} from "./tool-search-config.js"; +import { applyToolSchemaDirectoryCatalog } from "./tool-search-directory.js"; +import { MAX_TOOL_SCHEMA_DIRECTORY_PROMPT_CHARS } from "./tool-search-directory.js"; +import { + readToolSearchArgs, + readToolSearchCallArgs, + readToolSearchId, + ToolSearchRuntime, +} from "./tool-search-runtime.js"; +import { + TOOL_CALL_RAW_TOOL_NAME, + TOOL_DESCRIBE_RAW_TOOL_NAME, + TOOL_SEARCH_CODE_MODE_TOOL_NAME, + TOOL_SEARCH_CONTROL_TOOL_NAMES, + TOOL_SEARCH_RAW_TOOL_NAME, + type ToolSearchCatalogRef, + type ToolSearchMode, + type ToolSearchToolContext, +} from "./tool-search-types.js"; +import { jsonResult, type AnyAgentTool } from "./tools/common.js"; -export const TOOL_SEARCH_CODE_MODE_TOOL_NAME = "tool_search_code"; -export const TOOL_SEARCH_RAW_TOOL_NAME = "tool_search"; -export const TOOL_DESCRIBE_RAW_TOOL_NAME = "tool_describe"; -export const TOOL_CALL_RAW_TOOL_NAME = "tool_call"; - -const TOOL_SEARCH_CONTROL_TOOL_NAMES = new Set([ +export { + addClientToolsToToolCatalog, + applyToolCatalogCompaction, + clearToolSearchCatalog, + collectUniqueCatalogToolNames, + compactToolSearchCatalogEntry, + createToolSearchCatalogRef, + registerHeadlessToolSearchCatalog, +} from "./tool-search-catalog.js"; +export { resolveToolSearchConfig } from "./tool-search-config.js"; +export { + buildToolSchemaDirectoryPrompt, + estimateToolSchemaDirectoryToolNames, + resolveToolSearchCatalogTool, +} from "./tool-search-directory.js"; +export { ToolSearchRuntime } from "./tool-search-runtime.js"; +export { projectToolSearchTargetTranscriptMessages } from "./tool-search-transcript.js"; +export { + TOOL_CALL_RAW_TOOL_NAME, + TOOL_DESCRIBE_RAW_TOOL_NAME, TOOL_SEARCH_CODE_MODE_TOOL_NAME, TOOL_SEARCH_RAW_TOOL_NAME, - TOOL_DESCRIBE_RAW_TOOL_NAME, - TOOL_CALL_RAW_TOOL_NAME, -]); - -const TOOL_SCHEMA_DIRECTORY_CONTROL_TOOL_NAMES = new Set([ - TOOL_SEARCH_RAW_TOOL_NAME, - TOOL_DESCRIBE_RAW_TOOL_NAME, - TOOL_CALL_RAW_TOOL_NAME, -]); - -const DEFAULT_CODE_TIMEOUT_MS = 10_000; -const DEFAULT_SEARCH_LIMIT = 8; -const DEFAULT_MAX_SEARCH_LIMIT = 20; -const MAX_REUSABLE_CATALOG_SNAPSHOTS = 256; -const MAX_TOOL_SCHEMA_DIRECTORY_PROMPT_CHARS = 18_000; -const TOOL_DIRECTORY_IDENTIFIER_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/u; - -type ToolSearchMode = "code" | "tools" | "directory"; -type CatalogSource = "openclaw" | "mcp" | "client"; -type CatalogTool = AnyAgentTool | ToolDefinition; -type CatalogVisibilityOptions = { - includeMcp?: boolean; -}; -type UnknownToolRecoverySurface = "raw-tools" | "code-mode" | "tools"; -type UnknownToolErrorOptions = { - exactIdOnly?: boolean; - recoverySurface?: UnknownToolRecoverySurface; -}; -type ToolSearchCallOptions = CatalogVisibilityOptions & - UnknownToolErrorOptions & { - parentToolCallId?: string; - signal?: AbortSignal; - onUpdate?: AgentToolUpdateCallback; - }; - -type ReusableCatalogSnapshot = { - entries: ToolSearchCatalogEntry[]; - fingerprint: string; -}; - -export type ToolSearchCatalogToolExecutor = (params: { - tool: CatalogTool; - toolName: string; - source: CatalogSource; - sourceName?: string; - toolCallId: string; - parentToolCallId?: string; - input: unknown; - signal?: AbortSignal; - onUpdate?: AgentToolUpdateCallback; - acceptResultBeforeProjection: ( - result: AgentToolResult, - ) => Promise>; -}) => Promise>; - -/** Transcript projection for target tool calls made through Tool Search. */ -export type ToolSearchTargetTranscriptProjection = { - parentToolCallId?: string; - toolCallId: string; - toolName: string; - input: unknown; - result?: unknown; - isError?: boolean; - timestamp?: number; -}; - -/** Resolved Tool Search config after defaults, limits, and runtime support checks. */ -export type ToolSearchConfig = { - enabled: boolean; - mode: ToolSearchMode; - codeTimeoutMs: number; - searchDefaultLimit: number; - maxSearchLimit: number; -}; - -/** Per-run/session context used by Tool Search control tools. */ -export type ToolSearchToolContext = { - config?: OpenClawConfig; - runtimeConfig?: OpenClawConfig; - agentId?: string; - sessionKey?: string; - sessionId?: string; - runId?: string; - catalogRef?: ToolSearchCatalogRef; - abortSignal?: AbortSignal; - executeTool?: ToolSearchCatalogToolExecutor; - forceRestartSafeTools?: boolean; -}; - -/** Catalog entry retained behind compacted Tool Search control tools. */ -export type ToolSearchCatalogEntry = { - id: string; - source: CatalogSource; - sourceName?: string; - mcp?: PluginToolMcpMeta; - name: string; - label?: string; - description: string; - parameters?: unknown; - outputSchema?: TSchema; - tool: CatalogTool; -}; - -type ToolSearchDirectoryIntent = { - tokens: Set; - hasUrl: boolean; - hasFilePath: boolean; - hasMention: boolean; - hasSchedule: boolean; - hasCurrentFact: boolean; - hasMemoryRecall: boolean; -}; - -type ToolDirectoryFamily = "memory" | "web"; - -type ToolSearchCatalogSession = { - entries: ToolSearchCatalogEntry[]; - searchCount: number; - describeCount: number; - callCount: number; -}; - -export type ToolSearchCatalogRef = { - current?: ToolSearchCatalogSession; -}; - -type CodeModeBridgeMethod = "search" | "describe" | "call"; - -type CodeModeChildMessage = - | { type: "result"; ok: true; value: unknown } - | { type: "result"; ok: false; error?: string } - | { type: "log"; items?: unknown[] } - | { type: "bridge"; id?: unknown; method?: unknown; args?: unknown }; - -type CodeModeBridgeResultMessage = { type: "bridge-result"; id: string } & Result; - -const SESSION_CATALOGS_KEY = Symbol.for("openclaw.toolSearch.sessionCatalogs"); -const globalToolSearchState = globalThis as typeof globalThis & { - [SESSION_CATALOGS_KEY]?: Map; -}; -const sessionCatalogs = - globalToolSearchState[SESSION_CATALOGS_KEY] ?? - (globalToolSearchState[SESSION_CATALOGS_KEY] = new Map()); -const reusableCatalogSnapshots = new Map(); -const catalogFingerprints = new WeakMap(); -const catalogToolIdentities = new WeakMap(); -const untrustedSchemaIdentities = new WeakMap(); -let nextCatalogToolIdentity = 1; -let nextUntrustedSchemaIdentity = 1; - -function readToolSearchConfig(config?: OpenClawConfig): Record { - const tools = isRecord(config?.tools) ? config.tools : undefined; - const toolSearch = tools?.toolSearch; - if (toolSearch === true) { - return { enabled: true }; - } - if (toolSearch === false) { - return { enabled: false }; - } - return isRecord(toolSearch) ? toolSearch : {}; -} - -function readBoolean(value: unknown, fallback: boolean): boolean { - return typeof value === "boolean" ? value : fallback; -} - -function readInteger(value: unknown, fallback: number): number { - return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback; -} - -let toolSearchCodeModeSupportedForTest: boolean | undefined; -let toolSearchMinCodeTimeoutMsForTest: number | undefined; - -function isToolSearchCodeModeSupported(): boolean { - if (toolSearchCodeModeSupportedForTest !== undefined) { - return toolSearchCodeModeSupportedForTest; - } - return process.allowedNodeEnvironmentFlags.has("--permission"); -} - -function resolveMinCodeTimeoutMs(): number { - return toolSearchMinCodeTimeoutMsForTest ?? 1000; -} - -export function resolveToolSearchConfig(config?: OpenClawConfig): ToolSearchConfig { - const raw = readToolSearchConfig(config); - const rawMode = typeof raw.mode === "string" ? raw.mode : "code"; - const requestedMode: ToolSearchMode = - rawMode === "tools" || rawMode === "directory" || rawMode === "code" ? rawMode : "code"; - const mode: ToolSearchMode = - requestedMode === "code" && !isToolSearchCodeModeSupported() ? "tools" : requestedMode; - const configured = Object.keys(raw).some((key) => key !== "enabled"); - const maxSearchLimit = Math.max( - 1, - Math.min(50, readInteger(raw.maxSearchLimit, DEFAULT_MAX_SEARCH_LIMIT)), - ); - return { - enabled: readBoolean(raw.enabled, configured), - mode, - codeTimeoutMs: Math.max( - resolveMinCodeTimeoutMs(), - Math.min(60_000, readInteger(raw.codeTimeoutMs, DEFAULT_CODE_TIMEOUT_MS)), - ), - searchDefaultLimit: Math.max( - 1, - Math.min(maxSearchLimit, readInteger(raw.searchDefaultLimit, DEFAULT_SEARCH_LIMIT)), - ), - maxSearchLimit, - }; -} - -function sessionCatalogKeys(input: { - sessionId?: string; - sessionKey?: string; - agentId?: string; - runId?: string; -}): string[] { - const runId = input.runId?.trim(); - if (runId) { - return [`run:${runId}`]; - } - const keys: string[] = []; - if (input.sessionId?.trim()) { - keys.push(`session:${input.sessionId.trim()}`); - } - if (input.sessionKey?.trim()) { - keys.push(`key:${input.sessionKey.trim()}`); - } - if (input.agentId?.trim()) { - keys.push(`agent:${input.agentId.trim()}`); - } - return uniqueStrings(keys); -} - -function sessionCatalogKey(input: { - sessionId?: string; - sessionKey?: string; - agentId?: string; - runId?: string; -}): string | undefined { - 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 untrustedSchemaFingerprint(schema: unknown): string { - if (schema === null || typeof schema !== "object") { - return stableJsonFingerprint(schema); - } - const existing = untrustedSchemaIdentities.get(schema); - if (existing !== undefined) { - return `object:${existing}`; - } - const next = nextUntrustedSchemaIdentity; - nextUntrustedSchemaIdentity += 1; - untrustedSchemaIdentities.set(schema, next); - return `object:${next}`; -} - -function catalogEntriesFingerprint(entries: readonly ToolSearchCatalogEntry[]): string { - // Fingerprints include object identity for executable tools because function - // bodies are not JSON-stable but catalog reuse must not bind stale executors. - return entries - .map((entry) => - [ - entry.id, - entry.source, - entry.sourceName ?? "", - stableJsonFingerprint(entry.mcp), - entry.name, - entry.label ?? "", - entry.description, - // Remote/client schemas may be attacker-sized. Object identity still - // invalidates reuse when a schema object is replaced without walking it. - entry.source === "openclaw" - ? stableJsonFingerprint(entry.parameters) - : untrustedSchemaFingerprint(entry.parameters), - entry.source === "openclaw" - ? stableJsonFingerprint(entry.outputSchema) - : untrustedSchemaFingerprint(entry.outputSchema), - 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; - mcp?: PluginToolMcpMeta; -} { - const meta = getPluginToolMeta(tool as AnyAgentTool); - const pluginId = meta?.pluginId?.trim(); - const mcp = meta?.mcp; - if (mcp) { - return { - source: "mcp", - sourceName: mcp.safeServerName || pluginId || "mcp", - mcp, - }; - } - if (pluginId === "bundle-mcp") { - return { source: "mcp", sourceName: pluginId }; - } - if (pluginId) { - return { source: "openclaw", sourceName: pluginId }; - } - return { source: "openclaw", sourceName: "core" }; -} - -function makeCatalogId(tool: CatalogTool, source: CatalogSource, sourceName?: string): string { - const owner = sourceName?.trim() || "core"; - return `${source}:${owner}:${tool.name}`; -} - -function wrapCatalogTool(tool: AnyAgentTool, hookContext?: HookContext): AnyAgentTool { - if (!hookContext || isToolWrappedWithBeforeToolCallHook(tool)) { - return tool; - } - return wrapToolWithBeforeToolCallHook(tool, hookContext); -} - -function toCatalogEntry( - tool: CatalogTool, - sourceOverride?: CatalogSource, - hookContext?: HookContext, -): ToolSearchCatalogEntry { - 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), - source, - sourceName, - ...(source === "mcp" && classified.mcp ? { mcp: classified.mcp } : {}), - name: tool.name, - label: tool.label, - description: tool.description ?? "", - parameters: tool.parameters, - // Only locally loaded OpenClaw/core tools may declare model-visible output - // contracts. MCP and client metadata remains untrusted and deferred. - ...(source === "openclaw" && (tool as AnyAgentTool).outputSchema - ? { outputSchema: (tool as AnyAgentTool).outputSchema } - : {}), - tool: catalogTool, - }; -} - -function shouldCatalogTool(tool: AnyAgentTool): boolean { - if (TOOL_SEARCH_CONTROL_TOOL_NAMES.has(tool.name)) { - return false; - } - return tool.catalogMode !== "direct-only"; -} - -/** - * Register a catalog owned only by an explicit ref (no session keys), for - * headless callers like cron trigger evaluation. Registration internals stay - * module-private; this is the single public seam for ref-only catalogs. - */ -export function registerHeadlessToolSearchCatalog(params: { - catalogRef: ToolSearchCatalogRef; - tools: readonly AnyAgentTool[]; - hookContext?: HookContext; -}): void { - const { catalogRef, tools, hookContext } = params; - const entries = tools - .filter((tool) => shouldCatalogTool(tool)) - .map((tool) => { - const scopedTool = - hookContext && isToolWrappedWithBeforeToolCallHook(tool) - ? rewrapToolWithBeforeToolCallHook(tool, hookContext) - : tool; - return toCatalogEntry(scopedTool, undefined, hookContext); - }); - registerToolSearchCatalog({ catalogRef, entries }); -} - -export function collectUniqueCatalogToolNames(tools: readonly AnyAgentTool[]): Set { - const nameCounts = new Map(); - for (const tool of tools) { - if (shouldCatalogTool(tool)) { - nameCounts.set(tool.name, (nameCounts.get(tool.name) ?? 0) + 1); - } - } - return new Set( - Array.from(nameCounts) - .filter(([, count]) => count === 1) - .map(([name]) => name), - ); -} +} from "./tool-search-types.js"; +export type { + ToolSearchCatalogEntry, + ToolSearchCatalogRef, + ToolSearchCatalogToolExecutor, + ToolSearchConfig, + ToolSearchTargetTranscriptProjection, + ToolSearchToolContext, +} from "./tool-search-types.js"; function shouldExposeControlTool(name: string, mode: ToolSearchMode): boolean { if (name === TOOL_SEARCH_CODE_MODE_TOOL_NAME) { @@ -553,181 +88,6 @@ function shouldExposeControlTool(name: string, mode: ToolSearchMode): boolean { return false; } -function readMessageToolResultId(message: AgentMessage): string | undefined { - const record = message as unknown as Record; - const role = typeof record.role === "string" ? record.role : ""; - const canUseDirectId = role === "toolResult" || role === "tool"; - const direct = record.toolCallId ?? record.toolUseId ?? record.tool_use_id; - if (canUseDirectId && typeof direct === "string" && direct.trim()) { - return direct; - } - const content = record.content; - if (!Array.isArray(content)) { - return undefined; - } - for (const block of content) { - if (!isRecord(block)) { - continue; - } - if (block.type !== "toolResult") { - continue; - } - const nested = block.toolCallId ?? block.toolUseId ?? block.tool_use_id ?? block.id; - if (typeof nested === "string" && nested.trim()) { - return nested; - } - } - return undefined; -} - -function textFromToolSearchProjectionResult(result: unknown, isError: boolean): string { - if (isRecord(result)) { - const details = isRecord(result.details) ? result.details : undefined; - const detailError = details?.error; - if (typeof detailError === "string" && detailError.trim()) { - return detailError; - } - const content = result.content; - if (Array.isArray(content)) { - const text = content - .map((item) => (isRecord(item) && typeof item.text === "string" ? item.text : "")) - .filter(Boolean) - .join("\n"); - if (text.trim()) { - return text; - } - } - } - const safe = toJsonSafe(result); - if (typeof safe === "string") { - return safe; - } - const encoded = JSON.stringify(safe); - if (typeof encoded === "string") { - return encoded; - } - return isError ? "Tool Search target tool failed." : "Tool Search target tool completed."; -} - -function buildToolSearchTargetTranscriptMessages( - projection: ToolSearchTargetTranscriptProjection, -): AgentMessage[] { - const input = toJsonSafe(projection.input); - const timestamp = projection.timestamp ?? Date.now(); - const resultRecord = isRecord(projection.result) ? projection.result : undefined; - const resultContent = - Array.isArray(resultRecord?.content) && resultRecord.content.length > 0 - ? toJsonSafe(resultRecord.content) - : [ - { - type: "text", - text: textFromToolSearchProjectionResult( - projection.result, - projection.isError === true, - ), - }, - ]; - return [ - { - role: "assistant", - content: [ - { - type: "toolCall", - id: projection.toolCallId, - name: projection.toolName, - arguments: input, - input, - }, - ], - stopReason: "toolUse", - timestamp, - } as unknown as AgentMessage, - { - role: "toolResult", - toolCallId: projection.toolCallId, - toolName: projection.toolName, - isError: projection.isError === true, - content: resultContent, - timestamp, - } as unknown as AgentMessage, - ]; -} - -export function projectToolSearchTargetTranscriptMessages( - messages: AgentMessage[], - projections: readonly ToolSearchTargetTranscriptProjection[], -): AgentMessage[] { - if (projections.length === 0) { - return messages; - } - const byParent = new Map(); - const unmatched: ToolSearchTargetTranscriptProjection[] = []; - for (const projection of projections) { - const parent = projection.parentToolCallId?.trim(); - if (!parent) { - unmatched.push(projection); - continue; - } - const group = byParent.get(parent) ?? []; - group.push(projection); - byParent.set(parent, group); - } - const inserted = new Set(); - const projected: AgentMessage[] = []; - for (const message of messages) { - projected.push(message); - const toolResultId = readMessageToolResultId(message); - const group = toolResultId ? byParent.get(toolResultId) : undefined; - if (!group) { - continue; - } - for (const projection of group) { - projected.push(...buildToolSearchTargetTranscriptMessages(projection)); - inserted.add(projection); - } - } - for (const projection of [...unmatched, ...projections]) { - if (inserted.has(projection)) { - continue; - } - projected.push(...buildToolSearchTargetTranscriptMessages(projection)); - inserted.add(projection); - } - return projected; -} - -function freezeJsonSnapshot(value: unknown): unknown { - if (value === null || typeof value !== "object") { - return value; - } - for (const nested of Object.values(value)) { - freezeJsonSnapshot(nested); - } - return Object.freeze(value); -} - -/** Capture a stable JSON-safe result before delayed transcript settlement. */ -function snapshotToolSearchTargetTranscriptResult( - result: AgentToolResult, -): AgentToolResult { - const hasDetails = "details" in result; - const snapshot = toJsonSafe(result); - if (!isRecord(snapshot)) { - throw new Error("Tool Search target result could not be captured for transcript projection."); - } - if (hasDetails && !("details" in snapshot)) { - // `details` presence selects callValue unwrapping. JSON serialization drops - // an explicit undefined, so restore that marker before freezing the envelope. - snapshot.details = result.details === undefined ? undefined : toJsonSafe(result.details); - } - return freezeJsonSnapshot(snapshot) as AgentToolResult; -} - -/** Create an explicit catalog holder for callers that cannot rely on session keys. */ -export function createToolSearchCatalogRef(): ToolSearchCatalogRef { - return {}; -} - /** Replace visible tools with Tool Search controls and register hidden catalog entries. */ export function applyToolSearchCatalog(params: { tools: AnyAgentTool[]; @@ -739,13 +99,7 @@ export function applyToolSearchCatalog(params: { catalogRef?: ToolSearchCatalogRef; toolHookContext?: HookContext; shouldCatalogTool?: (tool: AnyAgentTool) => boolean; -}): { - tools: AnyAgentTool[]; - compacted: boolean; - catalogToolCount: number; - catalogRegistered: boolean; - catalogReused: boolean; -} { +}) { const config = resolveToolSearchConfig(params.config); return applyToolCatalogCompaction({ ...params, @@ -756,92 +110,7 @@ export function applyToolSearchCatalog(params: { }); } -/** Keep tool names discoverable while deferring heavyweight JSON schemas behind describe/call. */ -export function applyToolSchemaDirectoryCatalog(params: { - tools: AnyAgentTool[]; - config?: OpenClawConfig; - sessionId?: string; - sessionKey?: string; - agentId?: string; - runId?: string; - catalogRef?: ToolSearchCatalogRef; - toolHookContext?: HookContext; - hydrateToolNames?: Iterable; -}): { - tools: AnyAgentTool[]; - compacted: boolean; - catalogToolCount: number; - catalogRegistered: boolean; - catalogReused: boolean; -} { - const config = resolveToolSearchConfig(params.config); - if (!config.enabled) { - return { - tools: params.tools, - compacted: false, - catalogToolCount: 0, - catalogRegistered: false, - catalogReused: false, - }; - } - if (!params.tools.some((tool) => tool.name === TOOL_SEARCH_RAW_TOOL_NAME)) { - return { - tools: params.tools.filter((tool) => !TOOL_SEARCH_CONTROL_TOOL_NAMES.has(tool.name)), - compacted: false, - catalogToolCount: 0, - catalogRegistered: false, - catalogReused: false, - }; - } - const hydrateToolNames = new Set( - normalizeStringEntries(Array.from(params.hydrateToolNames ?? [])), - ); - const uniqueCatalogToolNames = collectUniqueCatalogToolNames(params.tools); - return applyToolCatalogCompaction({ - ...params, - enabled: config.enabled, - isVisibleControlTool: (tool) => TOOL_SCHEMA_DIRECTORY_CONTROL_TOOL_NAMES.has(tool.name), - isVisibleCatalogTool: (tool) => - hydrateToolNames.has(tool.name) && uniqueCatalogToolNames.has(tool.name), - }); -} - -export function buildToolSchemaDirectoryPrompt( - ctx: ToolSearchToolContext, - options?: CatalogVisibilityOptions, -): string { - const runtime = new ToolSearchRuntime( - ctx, - resolveToolSearchConfig(ctx.runtimeConfig ?? ctx.config), - ); - return formatToolSearchCatalogDirectory(runtime.all(options)); -} - -/** Resolve an exact hidden catalog tool name without exposing fuzzy search or catalog ids. */ -export function resolveToolSearchCatalogTool( - ctx: ToolSearchToolContext, - name: unknown, - options?: CatalogVisibilityOptions, -): AnyAgentTool | undefined { - if (typeof name !== "string") { - return undefined; - } - const needle = name.trim(); - if (!needle) { - return undefined; - } - try { - const matches = visibleCatalogEntries(resolveCatalog(ctx), options).filter( - (entry) => entry.name === needle, - ); - return matches.length === 1 ? (matches[0]?.tool as AnyAgentTool | undefined) : undefined; - } catch (error) { - if (error instanceof ToolInputError) { - return undefined; - } - throw error; - } -} +export { applyToolSchemaDirectoryCatalog }; /** Move client-provided tools into an existing Tool Search catalog. */ export function addClientToolsToToolSearchCatalog(params: { @@ -857,1366 +126,7 @@ export function addClientToolsToToolSearchCatalog(params: { if (config.mode === "directory") { return { tools: params.tools, compacted: false, catalogToolCount: 0 }; } - return addClientToolsToToolCatalog({ - ...params, - enabled: config.enabled, - }); -} - -/** Register catalog entries under run/session keys and optional direct refs. */ -function registerToolSearchCatalog(params: { - sessionId?: string; - sessionKey?: string; - agentId?: string; - runId?: string; - catalogRef?: ToolSearchCatalogRef; - entries: ToolSearchCatalogEntry[]; - append?: boolean; -}): ToolSearchCatalogSession | undefined { - const keys = sessionCatalogKeys(params); - const primaryKey = keys[0]; - if (!primaryKey && !params.catalogRef) { - return undefined; - } - const prior = params.append - ? (params.catalogRef?.current ?? (primaryKey ? sessionCatalogs.get(primaryKey) : undefined)) - : undefined; - const byId = new Map(); - for (const entry of prior?.entries ?? []) { - byId.set(entry.id, entry); - } - for (const entry of params.entries) { - byId.set(entry.id, entry); - byId.set(entry.name, entry); - } - const next = { - entries: uniqueValues(byId.values()).toSorted((a, b) => a.id.localeCompare(b.id)), - searchCount: prior?.searchCount ?? 0, - describeCount: prior?.describeCount ?? 0, - callCount: prior?.callCount ?? 0, - }; - catalogFingerprints.set(next, catalogEntriesFingerprint(next.entries)); - if (params.catalogRef) { - params.catalogRef.current = next; - } - for (const key of keys) { - sessionCatalogs.set(key, next); - } - return next; -} - -/** Clear Tool Search catalog state for a run/session/ref. */ -export function clearToolSearchCatalog(params: { - sessionId?: string; - sessionKey?: string; - agentId?: string; - runId?: string; - catalogRef?: ToolSearchCatalogRef; -}): void { - if (params.catalogRef) { - params.catalogRef.current = undefined; - } - 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 { - if (ctx.catalogRef?.current) { - return ctx.catalogRef.current; - } - const keys = sessionCatalogKeys({ - sessionId: ctx.sessionId, - sessionKey: ctx.sessionKey, - agentId: ctx.agentId, - runId: ctx.runId, - }); - for (const key of keys) { - const catalog = sessionCatalogs.get(key); - if (catalog) { - return catalog; - } - } - if (ctx.runId?.trim()) { - throw new ToolInputError("Tool Search catalog is unavailable for this run."); - } - const uniqueCatalogs = uniqueValues(sessionCatalogs.values()); - if (uniqueCatalogs.length === 1) { - const catalog = uniqueCatalogs[0]; - if (catalog) { - return catalog; - } - } - throw new ToolInputError("Tool Search catalog is unavailable for this run."); -} - -export function compactToolSearchCatalogEntry(entry: ToolSearchCatalogEntry) { - const output = - entry.source === "openclaw" ? compactToolOutputHint(entry.outputSchema) : undefined; - return { - id: entry.id, - source: entry.source, - sourceName: entry.sourceName, - ...(entry.mcp ? { mcp: entry.mcp } : {}), - name: entry.name, - label: entry.label, - description: entry.description, - // Remote MCP and client schemas are untrusted metadata. Keep them deferred - // rather than repeatedly traversing attacker-sized property maps on search. - input: entry.source === "openclaw" ? compactToolInputHint(entry.parameters) : "unknown", - ...(output ? { output } : {}), - }; -} - -function compactDirectoryDescription(description: string): string { - const normalized = description.replace(/\s+/g, " ").trim(); - if (normalized.length <= 180) { - return normalized; - } - return `${truncateUtf16Safe(normalized, 177).trimEnd()}...`; -} - -function formatToolDirectoryIdentifier(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed && TOOL_DIRECTORY_IDENTIFIER_RE.test(trimmed) ? trimmed : undefined; -} - -function formatToolDirectoryEntry( - entry: ReturnType, -): string | undefined { - if (entry.source !== "openclaw") { - return undefined; - } - const name = formatToolDirectoryIdentifier(entry.name); - if (!name) { - return undefined; - } - const description = compactDirectoryDescription(entry.description); - const ownerName = formatToolDirectoryIdentifier(entry.sourceName); - const owner = ownerName ? ` (${ownerName})` : ""; - return `- ${name}${owner}: ${description || "No description."}`; -} - -function renderToolSearchCatalogDirectory(lines: string[], total: number): string { - const omitted = total - lines.length; - const footer = - omitted > 0 - ? `${omitted} additional tools omitted. Use tool_search to find them, then tool_describe to load a full schema before tool_call.` - : "Call tool_describe with a listed tool name to load its full schema before using tool_call."; - return ["Available deferred-schema tools:", ...lines, "", footer].join("\n"); -} - -function formatToolSearchCatalogDirectory( - entries: Array>, -): string { - if (entries.length === 0) { - return "Available deferred-schema tools: none."; - } - const nameCounts = new Map(); - for (const entry of entries) { - nameCounts.set(entry.name, (nameCounts.get(entry.name) ?? 0) + 1); - } - const lines = entries - .filter((entry) => nameCounts.get(entry.name) === 1) - .toSorted((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)) - .map(formatToolDirectoryEntry) - .filter((line): line is string => Boolean(line)); - const fullDirectory = renderToolSearchCatalogDirectory(lines, entries.length); - if (fullDirectory.length <= MAX_TOOL_SCHEMA_DIRECTORY_PROMPT_CHARS) { - return fullDirectory; - } - - // Keep the directory deterministic and bounded; omitted names remain - // discoverable through the visible tool_search control. - let low = 0; - let high = lines.length; - while (low < high) { - const middle = Math.ceil((low + high) / 2); - if ( - renderToolSearchCatalogDirectory(lines.slice(0, middle), entries.length).length <= - MAX_TOOL_SCHEMA_DIRECTORY_PROMPT_CHARS - ) { - low = middle; - } else { - high = middle - 1; - } - } - return renderToolSearchCatalogDirectory(lines.slice(0, low), entries.length); -} - -const TOOL_DIRECTORY_HYDRATION_KEYWORDS: Array<{ - terms: readonly string[]; - toolHints: readonly string[]; - weight: number; -}> = [ - { - terms: ["search", "lookup", "look", "find", "current", "today", "price", "latest", "news"], - toolHints: ["searxng", "web"], - weight: 8, - }, - { - terms: ["url", "link", "page", "fetch", "read", "article", "http", "https"], - toolHints: ["fetch", "browser"], - weight: 8, - }, - { - terms: ["send", "reply", "message", "post", "react", "embed", "discord", "imessage"], - toolHints: ["message", "session", "send"], - weight: 7, - }, - { - terms: ["file", "path", "read", "write", "edit", "patch", "grep", "list"], - toolHints: ["read", "write", "edit", "grep", "find", "ls", "patch"], - weight: 6, - }, - { - terms: ["run", "command", "shell", "terminal", "build", "test", "pnpm", "git"], - toolHints: ["exec", "process"], - weight: 7, - }, - { - terms: [ - "remember", - "recall", - "memory", - "memories", - "known", - "history", - "previous", - "prior", - "earlier", - "decided", - "decision", - "discussed", - ], - toolHints: ["memory"], - weight: 6, - }, - { - terms: ["remind", "schedule", "later", "tomorrow", "daily", "weekly", "cron"], - toolHints: ["cron", "automation", "heartbeat"], - weight: 8, - }, - { - terms: ["image", "picture", "photo", "meme", "gif", "screenshot", "visual"], - toolHints: ["image", "vision", "browser"], - weight: 6, - }, - { - terms: ["audio", "voice", "speak", "tts", "transcribe"], - toolHints: ["audio", "voice", "tts"], - weight: 6, - }, -]; - -function readToolDirectoryIntent(query: string): ToolSearchDirectoryIntent { - const tokens = new Set(tokenize(query)); - const hasCurrentFact = ["current", "today", "latest", "price", "weather", "news"].some((term) => - tokens.has(term), - ); - const hasExplicitMemoryRecall = [ - "remember", - "recall", - "memory", - "memories", - "known", - "history", - "previous", - "prior", - "earlier", - "decided", - "decision", - "discussed", - ].some((term) => tokens.has(term)); - const hasIdentityRecall = - /\b(?:do you know|who (?:is|are|was)|what did (?:we|i|you|they)|when did (?:we|i|you|they))\b/iu.test( - query, - ); - return { - tokens, - hasUrl: tokens.has("http") || tokens.has("https") || /https?:\/\//iu.test(query), - hasFilePath: tokens.has("/") || /(^|\s)(\.{1,2}\/|\/|[a-z]:\\)/iu.test(query), - hasMention: /<@!?\d+>/u.test(query) || tokens.has("discord"), - hasSchedule: ["remind", "schedule", "later", "tomorrow", "daily", "weekly", "cron"].some( - (term) => tokens.has(term), - ), - hasCurrentFact, - hasMemoryRecall: hasExplicitMemoryRecall || (hasIdentityRecall && !hasCurrentFact), - }; -} - -function classifyDirectoryToolFamilies( - tool: Pick, - intent: ToolSearchDirectoryIntent, -): Set { - const toolText = `${tool.name} ${tool.description ?? ""}`.toLowerCase(); - const families = new Set(); - if (TOOL_SEARCH_CONTROL_TOOL_NAMES.has(tool.name)) { - return families; - } - const hasMemoryToolSignal = - /\b(?:memory|memories|recall|remember|history|prior|knowledge|libravdb)\b/iu.test(toolText) || - /(?:^|_)(?:memory|recall|remember|libravdb)(?:_|$)/iu.test(tool.name); - const hasWebToolSignal = - /\b(?:web|internet|online|browser|url|http|https|page|article|fetch|crawl|searxng|google|bing|brave|tavily|duckduckgo|serp)\b/iu.test( - toolText, - ) || - /(?:^|_)(?:web|fetch|browser|searxng|google|bing|brave|tavily|duckduckgo|serp)(?:_|$)/iu.test( - tool.name, - ); - const hasWebIntent = - intent.hasUrl || - intent.hasCurrentFact || - ["search", "lookup", "look", "find", "current", "today", "price", "latest", "news"].some( - (term) => intent.tokens.has(term), - ); - if (hasWebToolSignal && hasWebIntent) { - families.add("web"); - } - if (hasMemoryToolSignal && intent.hasMemoryRecall) { - families.add("memory"); - } - return families; -} - -function scoreDirectoryTool( - tool: Pick, - intent: ToolSearchDirectoryIntent, -) { - const toolText = `${tool.name} ${tool.description ?? ""}`.toLowerCase(); - const toolTokens = new Set(tokenize(toolText)); - let score = 0; - // Iterate catalog text so large prompts do not multiply scoring work for every tool. - for (const token of toolTokens) { - if (intent.tokens.has(token)) { - score += 2; - } - } - for (const group of TOOL_DIRECTORY_HYDRATION_KEYWORDS) { - if (!group.terms.some((term) => intent.tokens.has(term))) { - continue; - } - if (group.toolHints.some((hint) => toolText.includes(hint))) { - score += group.weight; - } - } - if (intent.hasUrl && /fetch|browser|web/iu.test(toolText)) { - score += 10; - } - if (intent.hasFilePath && /read|write|edit|grep|find|ls|file|patch/iu.test(toolText)) { - score += 8; - } - if (intent.hasMention && /message|discord|react|send/iu.test(toolText)) { - score += 8; - } - if (intent.hasSchedule && /cron|schedule|remind|heartbeat|automation/iu.test(toolText)) { - score += 8; - } - if ( - intent.hasCurrentFact && - /searxng|web|internet|online|fetch|weather|finance|price|google|bing|brave|tavily|duckduckgo|serp/iu.test( - toolText, - ) - ) { - score += 8; - } - if ( - intent.hasMemoryRecall && - /memory|memories|recall|remember|history|prior|knowledge|libravdb/iu.test(toolText) - ) { - score += 8; - } - return score; -} - -function expandDirectoryHydrationGroups(params: { - selectedNames: readonly string[]; - tools: readonly Pick[]; - intent: ToolSearchDirectoryIntent; - maxTools: number; -}): string[] { - if (params.maxTools <= 0) { - return []; - } - const emitted = new Set(); - const expandedFamilies = new Set(); - const expanded: string[] = []; - const toolsByName = new Map(params.tools.map((tool) => [tool.name, tool])); - const toolsByFamily = new Map(); - const selectedRank = new Map(params.selectedNames.map((name, index) => [name, index])); - for (const tool of params.tools) { - for (const family of classifyDirectoryToolFamilies(tool, params.intent)) { - const names = toolsByFamily.get(family) ?? []; - names.push(tool.name); - toolsByFamily.set(family, names); - } - } - for (const names of toolsByFamily.values()) { - names.sort( - (a, b) => - (selectedRank.get(a) ?? Number.MAX_SAFE_INTEGER) - - (selectedRank.get(b) ?? Number.MAX_SAFE_INTEGER) || a.localeCompare(b), - ); - } - for (const selectedName of params.selectedNames) { - if (expanded.length >= params.maxTools) { - break; - } - if (!emitted.has(selectedName)) { - expanded.push(selectedName); - emitted.add(selectedName); - } - if (expanded.length >= params.maxTools) { - break; - } - const selectedTool = toolsByName.get(selectedName); - if (!selectedTool) { - continue; - } - for (const family of classifyDirectoryToolFamilies(selectedTool, params.intent)) { - if (expandedFamilies.has(family)) { - continue; - } - expandedFamilies.add(family); - for (const groupedName of toolsByFamily.get(family) ?? []) { - if (expanded.length >= params.maxTools) { - return expanded; - } - if (emitted.has(groupedName)) { - continue; - } - expanded.push(groupedName); - emitted.add(groupedName); - } - } - } - return expanded; -} - -export function estimateToolSchemaDirectoryToolNames(params: { - tools: readonly AnyAgentTool[]; - query?: string; - maxTools?: number; - requiredToolNames?: Iterable; -}): string[] { - const maxTools = Math.max(0, Math.min(12, params.maxTools ?? 4)); - const hydratableTools: AnyAgentTool[] = []; - const externalToolNames = new Set(); - const uniqueCatalogToolNames = collectUniqueCatalogToolNames(params.tools); - for (const tool of params.tools) { - if (!uniqueCatalogToolNames.has(tool.name)) { - continue; - } - // MCP descriptions are untrusted; keep their schemas deferred until explicit describe/call. - if (classifyTool(tool).source === "mcp") { - externalToolNames.add(tool.name); - continue; - } - hydratableTools.push(tool); - } - const required = normalizeStringEntries(Array.from(params.requiredToolNames ?? [])).filter( - (name) => !externalToolNames.has(name), - ); - const requiredSet = new Set(required); - const query = params.query?.trim() ?? ""; - if (!query && required.length >= maxTools) { - return required.slice(0, maxTools); - } - const intent = readToolDirectoryIntent(query); - const scored = hydratableTools - .filter((tool) => !TOOL_SEARCH_CONTROL_TOOL_NAMES.has(tool.name)) - .map((tool) => ({ - name: tool.name, - score: requiredSet.has(tool.name) - ? Number.MAX_SAFE_INTEGER - : scoreDirectoryTool(tool, intent), - })) - .filter((entry) => entry.score > 0) - .toSorted((a, b) => b.score - a.score || a.name.localeCompare(b.name)); - const selected = uniqueStrings([...required, ...scored.map((entry) => entry.name)]); - return expandDirectoryHydrationGroups({ - selectedNames: selected, - tools: hydratableTools, - intent, - maxTools, - }); -} - -function describeEntry(entry: ToolSearchCatalogEntry) { - return { - ...compactToolSearchCatalogEntry(entry), - parameters: entry.parameters ?? {}, - ...(entry.outputSchema ? { outputSchema: entry.outputSchema } : {}), - }; -} - -function tokenize(input: string): string[] { - return normalizeStringEntries(input.toLowerCase().split(/[^a-z0-9_./:-]+/u)); -} - -function scoreEntry(entry: ToolSearchCatalogEntry, terms: string[]): number { - if (terms.length === 0) { - return 1; - } - const name = entry.name.toLowerCase(); - const id = entry.id.toLowerCase(); - const label = (entry.label ?? "").toLowerCase(); - const description = entry.description.toLowerCase(); - let score = 0; - for (const term of terms) { - if (name === term || id === term) { - score += 20; - } - if (name.includes(term)) { - score += 8; - } - if (id.includes(term)) { - score += 6; - } - if (label.includes(term)) { - score += 4; - } - if (description.includes(term)) { - score += 2; - } - } - return score; -} - -function visibleCatalogEntries( - catalog: ToolSearchCatalogSession, - options?: CatalogVisibilityOptions, -): ToolSearchCatalogEntry[] { - return options?.includeMcp === false - ? catalog.entries.filter((entry) => entry.source !== "mcp") - : catalog.entries; -} - -function tokenizeLookupValue(input: string): Set { - 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(); - 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); - const exactIdEntry = entries.find((candidate) => candidate.id === needle); - if (exactIdEntry) { - return exactIdEntry; - } - const namedEntries = entries.filter((candidate) => candidate.name === needle); - if (namedEntries.length > 1) { - throw new ToolInputError(`Ambiguous tool name: ${needle}; use an exact tool id.`); - } - const namedEntry = namedEntries[0]; - if (!namedEntry) { - throw new ToolInputError(formatUnknownToolIdError(needle, entries, errorOptions)); - } - return namedEntry; -} - -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( - formatUnknownToolIdError(needle, catalog.entries, { ...errorOptions, exactIdOnly: true }), - ); - } - return entry; -} - -function readId(args: unknown): string { - const params = asToolParamsRecord(args); - const value = params.id ?? params.toolId ?? params.name; - if (typeof value !== "string" || !value.trim()) { - throw new ToolInputError("id must be a non-empty string."); - } - return value.trim(); -} - -function readLimit(value: unknown, config: ToolSearchConfig): number { - if (value === undefined) { - return config.searchDefaultLimit; - } - if (typeof value !== "number" || !Number.isInteger(value) || value < 1) { - throw new ToolInputError("limit must be a positive integer."); - } - return Math.min(value, config.maxSearchLimit); -} - -function readSearchArgs(args: unknown, config: ToolSearchConfig): { query: string; limit: number } { - const params = asToolParamsRecord(args); - const query = params.query; - if (typeof query !== "string") { - throw new ToolInputError("query must be a string."); - } - const options = isRecord(params.options) ? params.options : undefined; - return { - query, - limit: readLimit(params.limit ?? options?.limit, config), - }; -} - -function readCallArgs(args: unknown): { id: string; input: unknown } { - const params = asToolParamsRecord(args); - const id = readId(params); - return { - id, - input: params.args ?? params.input ?? {}, - }; -} - -function getTelemetry(catalog: ToolSearchCatalogSession) { - const sources: Record = { - openclaw: 0, - mcp: 0, - client: 0, - }; - for (const entry of catalog.entries) { - sources[entry.source] += 1; - } - return { - catalogSize: catalog.entries.length, - sources, - searchCount: catalog.searchCount, - describeCount: catalog.describeCount, - callCount: catalog.callCount, - }; -} - -let schemaValidatorModulePromise: - | Promise - | undefined; - -async function validateCatalogOutputValue( - entry: ToolSearchCatalogEntry, - value: unknown, -): Promise< - ReturnType | undefined -> { - if (!entry.outputSchema) { - return undefined; - } - try { - schemaValidatorModulePromise ??= import("../plugins/schema-validator.js"); - const { validateJsonSchemaValue } = await schemaValidatorModulePromise; - return validateJsonSchemaValue({ - schema: entry.outputSchema as never, - // The shared validator fingerprints schema changes under the caller key, - // so a rebuilt same-id catalog cannot retain a stale compiled contract. - cacheKey: `tool-output:${entry.id}`, - value, - }); - } catch (error) { - throw new Error(`Tool "${entry.id}" has an invalid outputSchema.`, { cause: error }); - } -} - -async function assertCatalogOutputSchemaIsValid(entry: ToolSearchCatalogEntry): Promise { - // Compile before execution. A bad contract must not turn a successful side - // effect into a retryable post-execution schema failure. - await validateCatalogOutputValue(entry, undefined); -} - -async function assertCatalogOutputMatchesSchema( - entry: ToolSearchCatalogEntry, - result: AgentToolResult, -): Promise { - if (!entry.outputSchema) { - return; - } - if (isPreExecutionBlockedToolResult(result)) { - const details = unwrapToolResultValue(result); - const reason = - isRecord(details) && typeof details.reason === "string" && details.reason.trim() - ? details.reason - : "Tool call blocked by policy"; - throw new Error(`Tool "${entry.id}" was blocked before execution: ${reason}`); - } - const validation = await validateCatalogOutputValue(entry, unwrapToolResultValue(result)); - if (!validation) { - return; - } - if (validation.ok) { - return; - } - throw new Error( - `Tool "${entry.id}" returned details that do not match its declared outputSchema.`, - ); -} - -function sanitizeToolCallIdPart(value: string): string { - const trimmed = value.trim(); - const safe = trimmed.replace(/[^A-Za-z0-9_.:-]+/g, "_").slice(0, 120); - return safe || "call"; -} - -export class ToolSearchRuntime { - private callSequence = 0; - - constructor( - private readonly ctx: ToolSearchToolContext, - private readonly config: ToolSearchConfig, - ) {} - - search = async (query: string, options?: { limit?: number } & CatalogVisibilityOptions) => { - const catalog = resolveCatalog(this.ctx); - catalog.searchCount += 1; - const limit = readLimit(options?.limit, this.config); - const terms = tokenize(query); - return visibleCatalogEntries(catalog, options) - .map((entry) => ({ entry, score: scoreEntry(entry, terms) })) - .filter((hit) => hit.score > 0) - .toSorted((a, b) => b.score - a.score || a.entry.id.localeCompare(b.entry.id)) - .slice(0, limit) - .map((hit) => compactToolSearchCatalogEntry(hit.entry)); - }; - - all = (options?: CatalogVisibilityOptions) => { - const catalog = resolveCatalog(this.ctx); - return visibleCatalogEntries(catalog, options).map((entry) => - compactToolSearchCatalogEntry(entry), - ); - }; - - namespaceEntries = () => { - const catalog = resolveCatalog(this.ctx); - return catalog.entries.map((entry) => - Object.assign(compactToolSearchCatalogEntry(entry), { - parameters: entry.parameters ?? {}, - }), - ); - }; - - describe = async (id: string, options?: CatalogVisibilityOptions & UnknownToolErrorOptions) => { - const catalog = resolveCatalog(this.ctx); - catalog.describeCount += 1; - return describeEntry(findEntry(catalog, id, options, options)); - }; - - call = async (id: string, input?: unknown, options?: ToolSearchCallOptions) => { - const catalog = resolveCatalog(this.ctx); - const entry = findEntry(catalog, id, options, options); - return await this.callEntry(catalog, entry, input, options); - }; - - callExactId = async ( - id: string, - input?: unknown, - options?: { - parentToolCallId?: string; - signal?: AbortSignal; - onUpdate?: AgentToolUpdateCallback; - recoverySurface?: UnknownToolRecoverySurface; - }, - ) => { - const catalog = resolveCatalog(this.ctx); - const entry = findEntryByExactId(catalog, id, options); - return await this.callEntry(catalog, entry, input, options); - }; - - callValue = async (id: string, input?: unknown, options?: ToolSearchCallOptions) => - // Resolve, execute, and unwrap on the host. Code Mode otherwise builds a - // full description before every call and sends a larger envelope to QuickJS. - unwrapToolResultValue((await this.call(id, input, options)).result); - - isReplaySafeExactId = (id: string): boolean => { - let entry: ToolSearchCatalogEntry; - try { - const catalog = resolveCatalog(this.ctx); - entry = findEntryByExactId(catalog, id); - } catch { - return false; - } - if (entry.source !== "openclaw") { - return false; - } - const pluginMeta = getPluginToolMeta(entry.tool as Parameters[0]); - if (pluginMeta) { - return pluginMeta.mcp ? false : pluginMeta.replaySafe === true; - } - if (getChannelAgentToolMeta(entry.tool as never)) { - return false; - } - return isAgentToolReplaySafe(entry.tool); - }; - - private readonly callEntry = async ( - catalog: ToolSearchCatalogSession, - entry: ToolSearchCatalogEntry, - input?: unknown, - options?: { - parentToolCallId?: string; - signal?: AbortSignal; - onUpdate?: AgentToolUpdateCallback; - }, - ) => { - catalog.callCount += 1; - await assertCatalogOutputSchemaIsValid(entry); - const parentId = sanitizeToolCallIdPart(options?.parentToolCallId ?? "direct"); - const toolCallId = `tool_search_code:${parentId}:${entry.name}:${++this.callSequence}`; - const executeTool = - this.ctx.executeTool ?? - (async (params: Parameters[0]) => { - const result = await params.tool.execute( - params.toolCallId, - params.input, - params.signal, - params.onUpdate, - undefined as never, - ); - return await params.acceptResultBeforeProjection(result); - }); - const acceptResultBeforeProjection = async (candidate: AgentToolResult) => { - if (isPreExecutionBlockedToolResult(candidate)) { - await assertCatalogOutputMatchesSchema(entry, candidate); - } - const snapshot = snapshotToolSearchTargetTranscriptResult(candidate); - await assertCatalogOutputMatchesSchema(entry, snapshot); - return snapshot; - }; - const result = await executeTool({ - tool: entry.tool, - toolName: entry.name, - source: entry.source, - sourceName: entry.sourceName, - toolCallId, - parentToolCallId: options?.parentToolCallId, - input: input ?? {}, - signal: options?.signal ?? this.ctx.abortSignal, - onUpdate: options?.onUpdate, - acceptResultBeforeProjection, - }); - // Production executors queue the accepted snapshot. Repeat acceptance here - // so headless/custom executors cannot return a mutable unvalidated value. - const acceptedResult = await acceptResultBeforeProjection(result); - return { - tool: compactToolSearchCatalogEntry(entry), - result: acceptedResult, - }; - }; - - telemetry() { - return getTelemetry(resolveCatalog(this.ctx)); - } -} - -function unwrapToolResultValue(result: AgentToolResult): unknown { - return isRecord(result) && "details" in result ? result.details : result; -} - -/** Compact a native tool list into visible control tools plus hidden catalog entries. */ -export function applyToolCatalogCompaction(params: { - tools: AnyAgentTool[]; - enabled: boolean; - sessionId?: string; - sessionKey?: string; - agentId?: string; - runId?: string; - catalogRef?: ToolSearchCatalogRef; - toolHookContext?: HookContext; - isVisibleControlTool: (tool: AnyAgentTool) => boolean; - isVisibleCatalogTool?: (tool: AnyAgentTool) => boolean; - shouldCatalogTool?: (tool: AnyAgentTool) => boolean; -}): { - tools: AnyAgentTool[]; - compacted: boolean; - catalogToolCount: number; - catalogRegistered: boolean; - catalogReused: boolean; -} { - if (!params.enabled) { - 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); - if (!hasControlTool || (!key && !params.catalogRef)) { - return { - tools: params.tools.filter((tool) => !TOOL_SEARCH_CONTROL_TOOL_NAMES.has(tool.name)), - compacted: false, - catalogToolCount: 0, - catalogRegistered: false, - catalogReused: false, - }; - } - - const visible: AnyAgentTool[] = []; - const catalog: ToolSearchCatalogEntry[] = []; - const shouldCatalog = (tool: AnyAgentTool) => - shouldCatalogTool(tool) && (params.shouldCatalogTool?.(tool) ?? true); - for (const tool of params.tools) { - if (params.isVisibleControlTool(tool)) { - visible.push(tool); - continue; - } - if (TOOL_SEARCH_CONTROL_TOOL_NAMES.has(tool.name)) { - continue; - } - if (shouldCatalog(tool)) { - catalog.push(toCatalogEntry(tool, undefined, params.toolHookContext)); - if (!params.isVisibleCatalogTool?.(tool)) { - continue; - } - } - visible.push(tool); - } - 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, - runId: params.runId, - catalogRef: params.catalogRef, - entries: catalog, - append: false, - }); - if (registered) { - rememberReusableCatalog(reusableKey, registered); - } - return { - tools: visible, - compacted: catalog.length > 0, - catalogToolCount: catalog.length, - catalogRegistered: true, - catalogReused: false, - }; -} - -/** Append client-side tool definitions to an already registered catalog. */ -export function addClientToolsToToolCatalog(params: { - tools: ToolDefinition[]; - enabled: boolean; - sessionId?: string; - sessionKey?: string; - agentId?: string; - runId?: string; - catalogRef?: ToolSearchCatalogRef; -}): { tools: ToolDefinition[]; compacted: boolean; catalogToolCount: number } { - const key = sessionCatalogKey(params); - if (!params.enabled || (!key && !params.catalogRef)) { - return { tools: params.tools, compacted: false, catalogToolCount: 0 }; - } - const existing = params.catalogRef?.current ?? (key ? sessionCatalogs.get(key) : undefined); - if (!existing) { - return { tools: params.tools, compacted: false, catalogToolCount: 0 }; - } - registerToolSearchCatalog({ - sessionId: params.sessionId, - sessionKey: params.sessionKey, - agentId: params.agentId, - runId: params.runId, - catalogRef: params.catalogRef, - entries: params.tools.map((tool) => toCatalogEntry(tool, "client")), - append: true, - }); - return { tools: [], compacted: params.tools.length > 0, catalogToolCount: params.tools.length }; -} - -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 value.message; - } - if (value === null) { - return null; - } - switch (typeof value) { - case "string": - return value; - case "number": - case "boolean": - case "bigint": - case "symbol": - case "function": - return String(value); - default: - return Object.prototype.toString.call(value); - } - } -} - -async function runCodeMode(params: { - toolCallId: string; - ctx: ToolSearchToolContext; - code: string; - config: ToolSearchConfig; - signal?: AbortSignal; - onUpdate?: AgentToolUpdateCallback; -}) { - const runtime = new ToolSearchRuntime(params.ctx, params.config); - const logs: string[] = []; - const value = await runCodeModeChild({ - code: params.code, - config: params.config, - logs, - parentToolCallId: params.toolCallId, - runtime, - signal: params.signal, - onUpdate: params.onUpdate, - }); - return { - ok: true, - value: toJsonSafe(value), - logs, - telemetry: runtime.telemetry(), - }; -} - -function buildCodeModeChildArgs(): string[] { - if (!process.allowedNodeEnvironmentFlags.has("--permission")) { - throw new ToolInputError("tool_search_code requires a Node runtime with --permission support."); - } - return ["--permission", "--input-type=module", "--eval", TOOL_SEARCH_CODE_MODE_CHILD_SOURCE]; -} - -function isCodeModeBridgeMethod(value: unknown): value is CodeModeBridgeMethod { - return value === "search" || value === "describe" || value === "call"; -} - -async function runCodeModeBridgeRequest( - runtime: ToolSearchRuntime, - method: CodeModeBridgeMethod, - args: unknown, - options?: { - parentToolCallId?: string; - signal?: AbortSignal; - onUpdate?: AgentToolUpdateCallback; - }, -): Promise { - const values = Array.isArray(args) ? args : []; - switch (method) { - case "search": { - const query = values[0]; - if (typeof query !== "string") { - throw new ToolInputError("search query must be a string."); - } - const optionsLocal = isRecord(values[1]) ? values[1] : undefined; - return await runtime.search(query, { - limit: typeof optionsLocal?.limit === "number" ? optionsLocal.limit : undefined, - }); - } - case "describe": { - const id = values[0]; - if (typeof id !== "string") { - throw new ToolInputError("describe id must be a string."); - } - 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, - recoverySurface: "code-mode", - }); - } - } - throw new ToolInputError("Unsupported tool_search_code bridge method."); -} - -function appendToolSearchCodeStderrTail(current: string, chunk: string): string { - return appendBoundedTextTail(current, chunk, SESSION_TOOL_STDERR_TAIL_BYTES); -} - -function runCodeModeChild(params: { - code: string; - config: ToolSearchConfig; - logs: string[]; - parentToolCallId: string; - runtime: ToolSearchRuntime; - signal?: AbortSignal; - onUpdate?: AgentToolUpdateCallback; -}): Promise { - return new Promise((resolve, reject) => { - const child = spawn(process.execPath, buildCodeModeChildArgs(), { - cwd: os.tmpdir(), - env: {}, - // The worker returns logs/results over IPC and never writes stdout. - // Ignore it so an unused pipe cannot fill or surface unhandled errors. - stdio: ["ignore", "ignore", "pipe", "ipc"], - }); - let stderrTail = ""; - let settled = false; - let timedOut = false; - let exitRejectionTimer: ReturnType | undefined; - const bridgeAbortController = new AbortController(); - const settle = (callback: () => void) => { - if (settled) { - return; - } - settled = true; - if (timer) { - clearTimeout(timer); - } - if (exitRejectionTimer) { - clearTimeout(exitRejectionTimer); - } - params.signal?.removeEventListener("abort", abortFromParent); - child.kill(); - callback(); - }; - const abortFromParent: () => void = () => { - bridgeAbortController.abort(params.signal?.reason); - child.kill("SIGKILL"); - settle(() => reject(new Error("tool_search_code aborted"))); - }; - const timer: ReturnType | undefined = setTimeout(() => { - timedOut = true; - bridgeAbortController.abort(new Error("tool_search_code timed out")); - child.kill("SIGKILL"); - settle(() => reject(new Error("tool_search_code timed out"))); - }, params.config.codeTimeoutMs); - params.signal?.addEventListener("abort", abortFromParent, { once: true }); - if (params.signal?.aborted) { - abortFromParent(); - return; - } - - child.stderr?.setEncoding("utf8"); - child.stderr?.on("data", (chunk: string) => { - stderrTail = appendToolSearchCodeStderrTail(stderrTail, chunk); - }); - child.stderr?.on("error", (error) => { - settle(() => reject(error)); - }); - - child.on("error", (error) => { - settle(() => reject(error)); - }); - child.on("exit", (code, signal) => { - if (settled) { - return; - } - const rejectOnExit = () => { - const suffix = stderrTail.trim(); - const detail = suffix ? `: ${sliceUtf16Safe(suffix, -500)}` : ""; - settle(() => - reject( - new Error( - timedOut - ? "tool_search_code timed out" - : `tool_search_code child exited with ${signal ?? code}${detail}`, - ), - ), - ); - }; - if (code === 0 && signal === null) { - // A clean exit can race the final IPC result. Wait briefly before - // treating it as failure so flushed bridge/result messages can arrive. - exitRejectionTimer = setTimeout(rejectOnExit, 250); - return; - } - rejectOnExit(); - }); - child.on("message", (message: CodeModeChildMessage) => { - if (settled) { - return; - } - if (!isRecord(message) || typeof message.type !== "string") { - return; - } - if (message.type === "log") { - const items = Array.isArray(message.items) ? message.items : []; - params.logs.push(items.map((item) => String(item)).join(" ")); - return; - } - if (message.type === "result") { - if (message.ok) { - settle(() => resolve(message.value)); - } else { - settle(() => - reject(new Error(typeof message.error === "string" ? message.error : "code failed")), - ); - } - return; - } - if (message.type !== "bridge") { - return; - } - const id = typeof message.id === "string" ? message.id : ""; - const method = isCodeModeBridgeMethod(message.method) ? message.method : undefined; - if (!id || !method) { - return; - } - void runCodeModeBridgeRequest(params.runtime, method, message.args, { - parentToolCallId: params.parentToolCallId, - signal: bridgeAbortController.signal, - onUpdate: params.onUpdate, - }) - .then((value) => { - if (settled || !child.connected) { - return; - } - const response: CodeModeBridgeResultMessage = { - type: "bridge-result", - id, - ok: true, - value: toJsonSafe(value), - }; - child.send(response, () => undefined); - }) - .catch((error: unknown) => { - if (settled || !child.connected) { - return; - } - const response: CodeModeBridgeResultMessage = { - type: "bridge-result", - id, - ok: false, - error: error instanceof Error ? error.message : String(error), - }; - child.send(response, () => undefined); - }); - }); - - child.send({ - type: "run", - code: params.code, - timeoutMs: params.config.codeTimeoutMs, - }); - }); -} - -function readCode(args: unknown): string { - const params = asToolParamsRecord(args); - const code = params.code; - if (typeof code !== "string" || !code.trim()) { - throw new ToolInputError("code must be a non-empty string."); - } - return code; + return addClientToolsToToolCatalog({ ...params, enabled: config.enabled }); } /** Create Tool Search control tools for the current run/session context. */ @@ -2242,7 +152,14 @@ export function createToolSearchTools(ctx: ToolSearchToolContext): AnyAgentTool[ onUpdate?: AgentToolUpdateCallback, ): Promise> => jsonResult( - await runCodeMode({ toolCallId, ctx, code: readCode(args), config, signal, onUpdate }), + await runCodeMode({ + toolCallId, + ctx, + code: readToolSearchCode(args), + config, + signal, + onUpdate, + }), ), }, { @@ -2257,7 +174,7 @@ export function createToolSearchTools(ctx: ToolSearchToolContext): AnyAgentTool[ ), }), execute: async (_toolCallId: string, args: unknown): Promise> => { - const search = readSearchArgs(args, config); + const search = readToolSearchArgs(args, config); return jsonResult(await runtime.search(search.query, { limit: search.limit })); }, }, @@ -2270,7 +187,7 @@ export function createToolSearchTools(ctx: ToolSearchToolContext): AnyAgentTool[ id: Type.String({ description: "Tool search result id or tool name." }), }), execute: async (_toolCallId: string, args: unknown): Promise> => - jsonResult(await runtime.describe(readId(args))), + jsonResult(await runtime.describe(readToolSearchId(args))), }, { name: TOOL_CALL_RAW_TOOL_NAME, @@ -2283,15 +200,15 @@ export function createToolSearchTools(ctx: ToolSearchToolContext): AnyAgentTool[ ), }), execute: async ( - _toolCallId: string, + toolCallId: string, args: unknown, signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback, ): Promise> => { - const call = readCallArgs(args); + const call = readToolSearchCallArgs(args); return jsonResult( await runtime.call(call.id, call.input, { - parentToolCallId: _toolCallId, + parentToolCallId: toolCallId, signal, onUpdate, }), @@ -2307,15 +224,8 @@ const testing = { maxToolSchemaDirectoryPromptChars: MAX_TOOL_SCHEMA_DIRECTORY_PROMPT_CHARS, resolveToolSearchConfig, isToolSearchCodeModeSupported, - setToolSearchCodeModeSupportedForTest: (value: boolean | undefined) => { - toolSearchCodeModeSupportedForTest = value; - }, - setToolSearchMinCodeTimeoutMsForTest: (value: number | undefined) => { - toolSearchMinCodeTimeoutMsForTest = - typeof value === "number" && Number.isFinite(value) && value > 0 - ? Math.floor(value) - : undefined; - }, + setToolSearchCodeModeSupportedForTest, + setToolSearchMinCodeTimeoutMsForTest, applyToolSearchCatalog, addClientToolsToToolSearchCatalog, appendToolSearchCodeStderrTail, @@ -2325,4 +235,3 @@ const testing = { if (process.env.VITEST || process.env.NODE_ENV === "test") { (globalThis as Record)[Symbol.for("openclaw.toolSearchTestApi")] = testing; } -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */