refactor(agents): split tool search internals (#113296)

This commit is contained in:
Peter Steinberger
2026-07-24 03:04:14 -07:00
committed by GitHub
parent dc4c5887fa
commit 050475b9de
10 changed files with 2243 additions and 2179 deletions
-1
View File
@@ -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
+547
View File
@@ -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<string, ToolSearchCatalogSession>;
};
export const sessionCatalogs =
globalToolSearchState[SESSION_CATALOGS_KEY] ??
(globalToolSearchState[SESSION_CATALOGS_KEY] = new Map<string, ToolSearchCatalogSession>());
export const reusableCatalogSnapshots = new Map<
string,
{ entries: ToolSearchCatalogEntry[]; fingerprint: string }
>();
const catalogFingerprints = new WeakMap<ToolSearchCatalogSession, string>();
const catalogToolIdentities = new WeakMap<object, number>();
const untrustedSchemaIdentities = new WeakMap<object, number>();
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<object>()): string {
if (value === null || typeof value !== "object") {
return JSON.stringify(value) ?? "undefined";
}
if (seen.has(value)) {
return '"[Circular]"';
}
seen.add(value);
if (Array.isArray(value)) {
return `[${value.map((item) => stableJsonFingerprint(item, seen)).join(",")}]`;
}
const record = value as Record<string, unknown>;
const entries = Object.keys(record)
.toSorted()
.map((key) => `${JSON.stringify(key)}:${stableJsonFingerprint(record[key], seen)}`);
return `{${entries.join(",")}}`;
}
function catalogToolIdentity(tool: CatalogTool): number {
const existing = catalogToolIdentities.get(tool);
if (existing !== undefined) {
return existing;
}
const next = nextCatalogToolIdentity;
nextCatalogToolIdentity += 1;
catalogToolIdentities.set(tool, next);
return next;
}
function 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<string> {
const nameCounts = new Map<string, number>();
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<string, ToolSearchCatalogEntry>();
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 };
}
+261
View File
@@ -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<unknown> {
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<unknown> {
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<typeof setTimeout> | 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<typeof setTimeout> | 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;
}
+79
View File
@@ -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<string, unknown> {
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;
}
+486
View File
@@ -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<string>;
hasUrl: boolean;
hasFilePath: boolean;
hasMention: boolean;
hasSchedule: boolean;
hasCurrentFact: boolean;
hasMemoryRecall: boolean;
};
type ToolDirectoryFamily = "memory" | "web";
export function applyToolSchemaDirectoryCatalog(params: {
tools: AnyAgentTool[];
config?: Parameters<typeof resolveToolSearchConfig>[0];
sessionId?: string;
sessionKey?: string;
agentId?: string;
runId?: string;
catalogRef?: ToolSearchCatalogRef;
toolHookContext?: Parameters<typeof applyToolCatalogCompaction>[0]["toolHookContext"];
hydrateToolNames?: Iterable<string>;
}) {
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<typeof compactToolSearchCatalogEntry>,
): 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<ReturnType<typeof compactToolSearchCatalogEntry>>,
): string {
if (entries.length === 0) {
return "Available deferred-schema tools: none.";
}
const nameCounts = new Map<string, number>();
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<AnyAgentTool, "name" | "description">,
intent: ToolSearchDirectoryIntent,
): Set<ToolDirectoryFamily> {
const toolText = `${tool.name} ${tool.description ?? ""}`.toLowerCase();
const families = new Set<ToolDirectoryFamily>();
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<AnyAgentTool, "name" | "description">,
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<AnyAgentTool, "name" | "description">[];
intent: ToolSearchDirectoryIntent;
maxTools: number;
}): string[] {
if (params.maxTools <= 0) {
return [];
}
const emitted = new Set<string>();
const expandedFamilies = new Set<ToolDirectoryFamily>();
const expanded: string[] = [];
const toolsByName = new Map(params.tools.map((tool) => [tool.name, tool]));
const toolsByFamily = new Map<ToolDirectoryFamily, string[]>();
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>;
}): string[] {
const maxTools = Math.max(0, Math.min(12, params.maxTools ?? 4));
const hydratableTools: AnyAgentTool[] = [];
const externalToolNames = new Set<string>();
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,
});
}
+29
View File
@@ -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);
}
}
}
+429
View File
@@ -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<string> {
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<string, number>();
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<CatalogSource, number> = { 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<typeof import("../plugins/schema-validator.js")>
| undefined;
async function validateCatalogOutputValue(
entry: ToolSearchCatalogEntry,
value: unknown,
): Promise<
ReturnType<typeof import("../plugins/schema-validator.js").validateJsonSchemaValue> | 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<void> {
// Compile before execution so a bad contract cannot follow a successful side effect.
await validateCatalogOutputValue(entry, undefined);
}
async function assertCatalogOutputMatchesSchema(
entry: ToolSearchCatalogEntry,
result: AgentToolResult<unknown>,
): Promise<void> {
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<typeof getPluginToolMeta>[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<ToolSearchCatalogToolExecutor>[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<unknown>) => {
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>): unknown {
return isRecord(result) && "details" in result ? result.details : result;
}
+172
View File
@@ -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<string, unknown>;
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<string, ToolSearchTargetTranscriptProjection[]>();
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<ToolSearchTargetTranscriptProjection>();
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<unknown>,
): AgentToolResult<unknown> {
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<unknown>;
}
+153
View File
@@ -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<unknown>,
) => Promise<AgentToolResult<unknown>>;
}) => Promise<AgentToolResult<unknown>>;
/** 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;
};
+87 -2178
View File
File diff suppressed because it is too large Load Diff