improve: reduce compact tool orchestration turns (#109596)

* perf(agents): streamline compact tool orchestration

* fix(agents): preserve compact schema narrowing

* refactor(agents): focus legacy tool-search guidance

* fix(agents): defer MCP compact schema hints

* chore: drop release-owned changelog edit
This commit is contained in:
Peter Steinberger
2026-07-16 20:53:42 -07:00
committed by GitHub
parent 0c06de9e0e
commit 57cc2ca308
8 changed files with 315 additions and 51 deletions
+18 -9
View File
@@ -385,9 +385,13 @@ type ToolCatalogEntry = {
description: string;
source: "openclaw" | "mcp" | "client";
sourceName?: string;
input: string;
};
```
`input` is a bounded TypeScript-style signature for the common case. Use
`tools.describe(...)` when the exact full schema is still needed.
Plugin tools use `source: "openclaw"` with `sourceName` set to the owning
plugin id; there is no separate `"plugin"` source value. `source: "mcp"` is
used only for MCP entries in `sourceName`/`mcp` metadata (and is filtered out
@@ -407,6 +411,7 @@ Catalog helpers:
type ToolCatalog = {
search(query: string, options?: { limit?: number }): Promise<ToolCatalogEntry[]>;
describe(id: string): Promise<ToolCatalogEntryWithSchema>;
callValue(id: string, input?: unknown): Promise<unknown>;
call(id: string, input?: unknown): Promise<unknown>;
[safeToolName: string]: unknown;
};
@@ -417,17 +422,21 @@ Convenience tool functions are installed only for unambiguous safe names:
```typescript
const files = await tools.search("read local file");
const fileRead = await tools.describe(files[0].id);
const content = await tools.call(fileRead.id, { path: "README.md" });
const content = await tools.callValue(fileRead.id, { path: "README.md" });
// If the hidden catalog has an unambiguous `web_search` entry:
const hits = await tools.web_search({ query: "OpenClaw code mode" });
```
MCP catalog entries are not callable through `tools.call(...)` or convenience
functions in code mode; they are exposed only through the generated `MCP`
namespace. TypeScript-style declaration files are available through the
read-only `API` virtual file surface, so agents can inspect MCP signatures
without adding MCP schemas to the prompt:
`tools.callValue(...)` returns a normal tool's JSON `details` value directly.
`tools.call(...)` preserves the raw `{ tool, result }` envelope for callers
that need content blocks or other result metadata.
MCP catalog entries are not callable through `tools.callValue(...)`,
`tools.call(...)`, or convenience functions in code mode; they are exposed
only through the generated `MCP` namespace. TypeScript-style declaration files
are available through the read-only `API` virtual file surface, so agents can
inspect MCP signatures without adding MCP schemas to the prompt:
```typescript
const files = await API.list("mcp");
@@ -731,7 +740,7 @@ because their structured results cannot cross the QuickJS bridge.
MCP entries stay in the run-scoped catalog so policy, approvals, hooks,
telemetry, transcript projection, and exact tool ids remain shared with
normal tool execution. The guest-facing `ALL_TOOLS`, `tools.search(...)`,
`tools.describe(...)`, and `tools.call(...)` views omit MCP entries. The
`tools.describe(...)`, `tools.callValue(...)`, and `tools.call(...)` views omit MCP entries. The
generated `MCP.<server>.<tool>({ ...input })` namespace resolves back to the
exact catalog id and dispatches through the same executor path.
@@ -944,7 +953,7 @@ Code mode coverage should prove:
- all catalog-eligible effective non-MCP tools appear in `ALL_TOOLS`
- direct-only tools stay model-visible and do not appear in `ALL_TOOLS`
- denied tools do not appear in `ALL_TOOLS`
- `tools.search`, `tools.describe`, and `tools.call` work for OpenClaw tools
- `tools.search`, `tools.describe`, `tools.callValue`, and `tools.call` work for OpenClaw tools
- `API.list("mcp")` and `API.read("mcp/<server>.d.ts")` expose TypeScript-style
MCP declarations without a bridge/tool call
- MCP namespace `$api()` remains available as an inline fallback for schemas
@@ -981,7 +990,7 @@ Run these as integration or end-to-end tests when changing the runtime:
7. In `exec`, read `ALL_TOOLS` and assert the catalog-eligible effective test
tools are present while direct-only tools are absent.
8. In `exec`, call OpenClaw/plugin/client tools through `tools.search`,
`tools.describe`, and `tools.call`.
`tools.describe`, and `tools.callValue` (or raw `tools.call`).
9. In `exec`, call `API.list("mcp")` and `API.read("mcp/<server>.d.ts")` and
assert the declaration files describe visible MCP tools.
10. In `exec`, call MCP tools through `MCP.<server>.<tool>({ ...input })` and
+6 -2
View File
@@ -118,7 +118,9 @@ client-provided app tools.
`openclaw.tools.search(query, options?)`
Searches the effective catalog for the current run. Results are compact and safe
to put back into prompt context.
to put back into prompt context. Each hit includes a bounded TypeScript-style
`input` signature, such as `{ id: string; mode?: "drip" | "flood" }`, so the
model can skip `describe` when that signature is sufficient.
```js
const hits = await openclaw.tools.search("calendar event", { limit: 5 });
@@ -134,7 +136,9 @@ const calendarCreate = await openclaw.tools.describe("mcp:calendar:create_event"
`openclaw.tools.call(id, args)`
Calls a selected tool through OpenClaw.
Calls a selected tool through OpenClaw and returns the raw `{ tool, result }`
envelope. JSON-returning tools normally place their value in
`result.details`.
```js
await openclaw.tools.call(calendarCreate.id, {
+4 -4
View File
@@ -72,11 +72,11 @@ describe("headless Code Mode", () => {
await runCodeModeScriptHeadless({
ctx,
code: `
const first = await tools.call("openclaw:core:headless_first", {});
const second = await tools.call("openclaw:core:headless_second", {
value: first.result.details.value,
const first = await tools.callValue("openclaw:core:headless_first", {});
const second = await tools.callValue("openclaw:core:headless_second", {
value: first.value,
});
return second.result.details;
return second;
`,
wallClockMs: 120_000,
}),
+27 -10
View File
@@ -387,15 +387,32 @@ describe("Code Mode", () => {
expect(execTool.description).toContain("Node.js modules");
expect(execTool.description).toContain("`require`/`import` are NOT available");
expect(execTool.description).toContain("`tools.search(query)`");
expect(execTool.description).toContain("one exec invocation");
expect(execTool.description).toContain("`ALL_TOOLS` is the complete compact catalog");
expect(execTool.description).toContain("`tools.search(query: string, options?)`");
expect(execTool.description).toContain("enabled catalog tools allowed by policy");
expect(execTool.description).toContain("`tools.describe(entry.id)`");
expect(execTool.description).toContain("`tools.call(entry.id, args)`");
expect(execTool.description).toContain("`tools.describe(id: string)`");
expect(execTool.description).toContain("`tools.callValue(id: string, args?)`");
expect(execTool.description).toContain("`tools.call(id: string, args?)`");
expect(execTool.description).toContain("Never invent or transform a tool id");
expect(execTool.description).toContain("returns its JSON value directly");
expect(execTool.description).toContain("const hit = ALL_TOOLS.find");
expect(execTool.description).toContain('"javascript" or "typescript"');
expect(parameters.properties?.code?.description).toContain("`tools` object");
expect(parameters.properties?.code?.description).toContain(
"`tools.search` takes a query string, not an object",
);
expect(parameters.properties?.code?.description).toContain(
"Select exact ids from `ALL_TOOLS` or `tools.search`",
);
expect(parameters.properties?.code?.description).toContain(
"never put dependent calls in Promise.all",
);
expect(parameters.properties?.code?.description).toContain("`ALL_TOOLS`");
expect(parameters.properties?.code?.description).toContain("Node built-in modules are not");
expect(parameters.properties?.restartSafe?.description).toContain(
"Leave unset for ordinary calls",
);
expect(parameters.properties?.language?.description).toContain(
'Must be "javascript" or "typescript"',
);
@@ -443,7 +460,7 @@ describe("Code Mode", () => {
const description = compacted.tools[0]?.description ?? "";
// Base tool guidance always stays; MCP/API and namespace guidance drop out so
// the model never probes an empty virtual API surface.
expect(description).toContain("`tools.search(query)`");
expect(description).toContain("`tools.search(query: string, options?)`");
expect(description).not.toContain("API.list");
expect(description).not.toContain("MCP tools are available only through");
expect(description).not.toContain("Registered plugin namespaces are available");
@@ -849,10 +866,9 @@ describe("Code Mode", () => {
waitTool: expectDefined(codeModeTools[1], "codeModeTools[1] test invariant"),
code: `
const hits = await tools.search("ticket", { limit: 1 });
const described = await tools.describe(hits[0].id);
const called = await tools.call(described.id, { value: "ship" });
const called = await tools.callValue(hits[0].id, { value: "ship" });
text("created");
return called.result.details;
return called;
`,
});
@@ -862,6 +878,7 @@ describe("Code Mode", () => {
input: { value: "ship" },
});
expect(details.output).toEqual([{ type: "text", text: "created" }]);
expect(details.telemetry).toMatchObject({ searchCount: 1, describeCount: 0, callCount: 1 });
expect(ticket.execute).toHaveBeenCalledTimes(1);
});
@@ -901,8 +918,8 @@ describe("Code Mode", () => {
code: `
const ids = [];
for (let index = 0; index < 5; index += 1) {
const called = await tools.call("fake_create_ticket", { value: index });
ids.push(called.result.details.input.value);
const called = await tools.callValue("fake_create_ticket", { value: index });
ids.push(called.input.value);
}
return ids;
`,
+25 -10
View File
@@ -86,7 +86,7 @@ type CodeModeConfig = {
maxSearchLimit: number;
};
type CodeModeBridgeMethod = "search" | "describe" | "call" | "yield" | "namespace";
type CodeModeBridgeMethod = "search" | "describe" | "call" | "callValue" | "yield" | "namespace";
type PendingBridgeRequest = {
id: string;
@@ -586,14 +586,26 @@ async function runBridgeRequest(params: {
if (typeof id !== "string") {
throw new ToolInputError("call id must be a string.");
}
const described = await params.runtime.describe(id, {
value = await params.runtime.call(id, values[1] ?? {}, {
includeMcp: false,
recoverySurface: "tools",
});
value = await params.runtime.callExactId(described.id, values[1] ?? {}, {
parentToolCallId: params.parentToolCallId,
signal: params.signal,
onUpdate: params.onUpdate,
recoverySurface: "tools",
});
break;
}
case "callValue": {
const id = values[0];
if (typeof id !== "string") {
throw new ToolInputError("callValue id must be a string.");
}
value = await params.runtime.callValue(id, values[1] ?? {}, {
includeMcp: false,
parentToolCallId: params.parentToolCallId,
signal: params.signal,
onUpdate: params.onUpdate,
recoverySurface: "tools",
});
break;
}
@@ -1052,7 +1064,10 @@ export async function runCodeModeScriptHeadless(params: {
enforceSnapshotPayloadLimits({ snapshotBytes: result.snapshotBytes, config, output });
const requestedToolCalls = result.pendingRequests.filter(
(request) => request.method === "call" || request.method === "namespace",
(request) =>
request.method === "call" ||
request.method === "callValue" ||
request.method === "namespace",
).length;
toolCallCount += requestedToolCalls;
if (toolCallCount > maxToolCalls) {
@@ -1140,7 +1155,7 @@ function pendingBridgeRequestsReplaySafe(
) {
return true;
}
if (request.method !== "call") {
if (request.method !== "call" && request.method !== "callValue") {
return false;
}
const id = Array.isArray(request.args) ? request.args[0] : undefined;
@@ -1274,7 +1289,7 @@ function createCodeModeExecDescription(
? " Registered plugin namespaces are available as direct globals and through `namespaces` when their required tools are visible in the run catalog."
: "";
return (
"Run JavaScript or TypeScript in OpenClaw code mode. Use `return` to pass the final value back to the agent; awaited calls without a returned value complete as `null`. Node.js modules and `require`/`import` are NOT available; for any shell, file, network, or external action, use enabled catalog tools allowed by policy from inside your code: `tools.search(query)` to find catalog entries, `tools.describe(entry.id)` for the input schema, then `tools.call(entry.id, args)`." +
"Run JavaScript or TypeScript in OpenClaw code mode. Use `return` to pass the final value back to the agent; awaited calls without a returned value complete as `null`. Prefer one exec invocation for a complete dependent workflow: select tools, call them, and process their results in the same program. Await prerequisites before later calls; parallelize only independent work. `ALL_TOOLS` is the complete compact catalog with exact ids and input hints. Select from it directly when practical, use `tools.search(query: string, options?)` when lookup is ambiguous, and use `tools.describe(id: string)` only when the compact input hint is insufficient. Never invent or transform a tool id. `tools.callValue(id: string, args?)` executes a tool and returns its JSON value directly; `tools.call(id: string, args?)` preserves the raw `{ tool, result }` envelope. Example: `const hit = ALL_TOOLS.find((entry) => entry.description.includes('weather')) ?? (await tools.search('weather'))[0]; return await tools.callValue(hit.id, {});`. Node.js modules and `require`/`import` are NOT available; for any shell, file, network, or external action, use enabled catalog tools allowed by policy from inside your code." +
mcpGuidance +
namespaceGuidance +
' The `language` field accepts only "javascript" or "typescript"; do not pass "bash", "shell", or other values.' +
@@ -1727,7 +1742,7 @@ export function createCodeModeTools(ctx: CodeModeToolContext): AnyAgentTool[] {
code: Type.Optional(
Type.String({
description:
"JavaScript or TypeScript source to run. The `tools` object (search/describe/call), `ALL_TOOLS`, `API` virtual declaration files, and registered namespace globals are available in scope; Node built-in modules are not.",
"JavaScript or TypeScript source for one complete workflow. Select exact ids from `ALL_TOOLS` or `tools.search`; never invent ids. `tools.search` takes a query string, not an object. Keep dependent operations in this program, never put dependent calls in Promise.all, and return the final value. `API` virtual declaration files and registered namespace globals are also available in scope; Node built-in modules are not.",
}),
),
command: Type.Optional(
@@ -1742,7 +1757,7 @@ export function createCodeModeTools(ctx: CodeModeToolContext): AnyAgentTool[] {
restartSafe: Type.Optional(
Type.Boolean({
description:
"Set true for read-only work that OpenClaw may reconstruct after a gateway restart. This rejects side-effecting catalog tools and plugin namespaces.",
"Set true only when every catalog call is explicitly replay-safe and OpenClaw may reconstruct the work after a gateway restart. Leave unset for ordinary calls; true rejects unmarked or side-effecting tools and plugin namespaces.",
}),
),
}),
+3 -1
View File
@@ -13,7 +13,7 @@ const require = createRequire(import.meta.url);
const QUICKJS_WASM_PATH = require.resolve("quickjs-wasi/quickjs.wasm");
let quickJsWasmModulePromise: Promise<WebAssembly.Module> | undefined;
type CodeModeBridgeMethod = "search" | "describe" | "call" | "yield" | "namespace";
type CodeModeBridgeMethod = "search" | "describe" | "call" | "callValue" | "yield" | "namespace";
type CodeModeConfig = {
timeoutMs: number;
@@ -275,6 +275,7 @@ const CONTROLLER_SOURCE = String.raw`
search: { value: (query, options) => request("search", [query, options]), enumerable: true },
describe: { value: (id) => request("describe", [id]), enumerable: true },
call: { value: (id, input) => request("call", [id, input]), enumerable: true },
callValue: { value: (id, input) => request("callValue", [id, input]), enumerable: true },
});
function normalizeApiPath(value) {
@@ -390,6 +391,7 @@ function createHostRequestHandler(params: {
method !== "search" &&
method !== "describe" &&
method !== "call" &&
method !== "callValue" &&
method !== "yield" &&
method !== "namespace"
) {
+92
View File
@@ -212,6 +212,98 @@ describe("Tool Search", () => {
}
});
it("guides structured control tools toward compact catalog calls", () => {
const tools = createToolSearchTools({ config: {} as never });
const byName = new Map(tools.map((tool) => [tool.name, tool]));
expect(byName.get(TOOL_SEARCH_CODE_MODE_TOOL_NAME)?.description).toContain(
"search(query: string, options?)",
);
expect(byName.get(TOOL_SEARCH_CODE_MODE_TOOL_NAME)?.description).toContain(
"JSON values normally live in `result.details`",
);
expect(byName.get(TOOL_SEARCH_RAW_TOOL_NAME)?.description).toContain(
"use tool_describe only when you need its input schema",
);
expect(byName.get(TOOL_DESCRIBE_RAW_TOOL_NAME)?.description).toContain(
"when its input is not already clear",
);
});
it("includes bounded input signatures in compact search hits", async () => {
const target = pluginTool("fake_update", "Update a fake record");
const openTarget = pluginTool("fake_open", "Accept constrained open input");
const mcpTarget = mcpPluginTool("remote_echo", "Echo through remote MCP");
target.parameters = {
type: "object",
required: ["id"],
properties: {
id: { type: "string" },
mode: { type: "string", enum: ["drip", "flood"] },
policy: { enum: ["auto", { mode: "custom" }] },
nested: {
type: "array",
items: {
type: "array",
items: {
type: "array",
items: {
type: "array",
items: { type: "array", items: { type: "string" } },
},
},
},
},
zones: { type: "array", items: { type: "string", enum: ["north", "south"] } },
},
};
openTarget.parameters = {
type: "object",
required: ["token"],
additionalProperties: true,
};
const config = { tools: { toolSearch: { mode: "tools" } } } as never;
applyToolSearchCatalog({
tools: [
fakeTool(TOOL_SEARCH_RAW_TOOL_NAME, "search"),
fakeTool(TOOL_DESCRIBE_RAW_TOOL_NAME, "describe"),
fakeTool(TOOL_CALL_RAW_TOOL_NAME, "call"),
target,
openTarget,
mcpTarget,
],
config,
sessionId: "session-input-hint",
});
const runtimeTools = createToolSearchTools({ config, sessionId: "session-input-hint" });
const search = expectDefined(
runtimeTools.find((tool) => tool.name === TOOL_SEARCH_RAW_TOOL_NAME),
"search tool",
);
const result = resultDetails(await search.execute("call-search", { query: "update record" }));
expect(result).toEqual([
expect.objectContaining({
name: "fake_update",
input:
'{ id: string; mode?: "drip" | "flood"; nested?: Array<Array<Array<Array<unknown>>>>; policy?: unknown; zones?: Array<"north" | "south"> }',
}),
]);
expect(JSON.stringify(result)).not.toContain("parameters");
const openResult = resultDetails(
await search.execute("call-search-open", { query: "constrained open input" }),
);
expect(openResult).toContainEqual(
expect.objectContaining({ name: "fake_open", input: "{ ... }" }),
);
const mcpResult = resultDetails(
await search.execute("call-search-mcp", { query: "remote echo" }),
);
expect(mcpResult).toContainEqual(expect.objectContaining({ name: "remote_echo" }));
expect(mcpResult).not.toContainEqual(expect.objectContaining({ input: expect.anything() }));
});
it("compacts plugin tools behind the code surface and can search, describe, and call them", async () => {
const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode");
const alpha = pluginTool("fake_create_ticket", "Create a ticket in the fake tracker");
+140 -15
View File
@@ -53,6 +53,10 @@ 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 MAX_COMPACT_INPUT_HINT_CHARS = 300;
const MAX_COMPACT_INPUT_PROPERTIES = 16;
const MAX_COMPACT_SCHEMA_DEPTH = 4;
const MAX_COMPACT_UNION_TYPES = 4;
const TOOL_DIRECTORY_IDENTIFIER_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/u;
type ToolSearchMode = "code" | "tools" | "directory";
@@ -66,6 +70,12 @@ type UnknownToolErrorOptions = {
exactIdOnly?: boolean;
recoverySurface?: UnknownToolRecoverySurface;
};
type ToolSearchCallOptions = CatalogVisibilityOptions &
UnknownToolErrorOptions & {
parentToolCallId?: string;
signal?: AbortSignal;
onUpdate?: AgentToolUpdateCallback;
};
type ReusableCatalogSnapshot = {
entries: ToolSearchCatalogEntry[];
@@ -1141,6 +1151,116 @@ function resolveCatalog(ctx: ToolSearchToolContext): ToolSearchCatalogSession {
throw new ToolInputError("Tool Search catalog is unavailable for this run.");
}
function compactSchemaType(schema: unknown, depth = 0): string {
if (!isRecord(schema) || depth >= MAX_COMPACT_SCHEMA_DEPTH) {
return "unknown";
}
const enumValues =
Array.isArray(schema.enum) &&
schema.enum.length > 0 &&
schema.enum.length <= 6 &&
schema.enum.every(
(value): value is string | number | boolean | null =>
value === null ||
typeof value === "string" ||
(typeof value === "number" && Number.isFinite(value)) ||
typeof value === "boolean",
)
? schema.enum
: [];
if (enumValues.length > 0 && enumValues.length <= 6) {
const rendered = enumValues.map((value) => JSON.stringify(value)).join(" | ");
if (rendered.length <= 96) {
return rendered;
}
}
const type = schema.type;
if (Array.isArray(type)) {
if (type.length > MAX_COMPACT_UNION_TYPES) {
return "unknown";
}
const types = type
.filter((value): value is string => typeof value === "string")
.map((value) => compactSchemaType({ ...schema, type: value }, depth + 1));
return types.length > 0 ? types.join(" | ") : "unknown";
}
if (type === "integer" || type === "number") {
return "number";
}
if (type === "array") {
return `Array<${compactSchemaType(schema.items, depth + 1)}>`;
}
if (type === "string" || type === "boolean" || type === "null" || type === "object") {
return type;
}
return "unknown";
}
function compactInputHint(parameters: unknown): string {
if (!isRecord(parameters)) {
return "unknown";
}
if (!isRecord(parameters.properties)) {
if (parameters.type !== "object") {
return compactSchemaType(parameters);
}
const hasRequired =
Array.isArray(parameters.required) &&
parameters.required.some((value) => typeof value === "string");
return hasRequired || parameters.additionalProperties !== false ? "{ ... }" : "{}";
}
const properties = parameters.properties;
const requiredValues = Array.isArray(parameters.required) ? parameters.required : [];
const required = new Set(
requiredValues
.slice(0, MAX_COMPACT_INPUT_PROPERTIES)
.filter((value): value is string => typeof value === "string"),
);
// Search hits cross the model/guest boundary. Required-first sorting and
// work/output bounds keep prompt bytes deterministic without exposing full schemas.
const selected = new Set<string>();
const keys: string[] = [];
for (const key of required) {
if (Object.hasOwn(properties, key)) {
selected.add(key);
keys.push(key);
}
}
let omitted =
requiredValues.length > MAX_COMPACT_INPUT_PROPERTIES ||
requiredValues
.slice(0, MAX_COMPACT_INPUT_PROPERTIES)
.some((value) => typeof value === "string" && !Object.hasOwn(properties, value)) ||
parameters.additionalProperties === true;
for (const key in properties) {
if (!Object.hasOwn(properties, key) || selected.has(key)) {
continue;
}
if (keys.length >= MAX_COMPACT_INPUT_PROPERTIES) {
omitted = true;
break;
}
selected.add(key);
keys.push(key);
}
keys.sort((a, b) => Number(required.has(b)) - Number(required.has(a)) || a.localeCompare(b));
const parts: string[] = [];
for (const key of keys) {
const name = /^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(key) ? key : JSON.stringify(key);
const part = `${name}${required.has(key) ? "" : "?"}: ${compactSchemaType(properties[key])}`;
const next = `{ ${[...parts, part].join("; ")} }`;
if (next.length > MAX_COMPACT_INPUT_HINT_CHARS) {
omitted = true;
break;
}
parts.push(part);
}
if (parts.length === 0) {
return keys.length === 0 && !omitted ? "{}" : "{ ... }";
}
return `{ ${parts.join("; ")}${omitted ? "; ..." : ""} }`;
}
function compactEntry(entry: ToolSearchCatalogEntry) {
return {
id: entry.id,
@@ -1150,6 +1270,9 @@ function compactEntry(entry: ToolSearchCatalogEntry) {
name: entry.name,
label: entry.label,
description: entry.description,
// MCP schemas are server-provided, untrusted metadata. Keep them deferred
// until the model explicitly describes or calls the selected tool.
...(entry.source === "mcp" ? {} : { input: compactInputHint(entry.parameters) }),
};
}
@@ -1779,18 +1902,9 @@ export class ToolSearchRuntime {
return describeEntry(findEntry(catalog, id, options, options));
};
call = async (
id: string,
input?: unknown,
options?: {
parentToolCallId?: string;
signal?: AbortSignal;
onUpdate?: AgentToolUpdateCallback;
recoverySurface?: UnknownToolRecoverySurface;
},
) => {
call = async (id: string, input?: unknown, options?: ToolSearchCallOptions) => {
const catalog = resolveCatalog(this.ctx);
const entry = findEntry(catalog, id, undefined, options);
const entry = findEntry(catalog, id, options, options);
return await this.callEntry(catalog, entry, input, options);
};
@@ -1809,6 +1923,11 @@ export class ToolSearchRuntime {
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 {
@@ -1875,6 +1994,10 @@ export class ToolSearchRuntime {
}
}
function unwrapToolResultValue(result: AgentToolResult<unknown>): 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[];
@@ -2324,7 +2447,7 @@ export function createToolSearchTools(ctx: ToolSearchToolContext): AnyAgentTool[
name: TOOL_SEARCH_CODE_MODE_TOOL_NAME,
label: "Tool Search Code",
description:
"Run JavaScript in an isolated Node subprocess with openclaw.tools.search, openclaw.tools.describe, and openclaw.tools.call for large tool catalogs.",
"Run JavaScript in an isolated Node subprocess over a large tool catalog. APIs: `openclaw.tools.search(query: string, options?)`, `openclaw.tools.describe(id: string)`, and `openclaw.tools.call(id: string, args?)`. Search takes a positional query string. Call returns `{ tool, result }`; JSON values normally live in `result.details`.",
parameters: Type.Object({
code: Type.String({
description:
@@ -2344,7 +2467,8 @@ export function createToolSearchTools(ctx: ToolSearchToolContext): AnyAgentTool[
{
name: TOOL_SEARCH_RAW_TOOL_NAME,
label: "Tool Search",
description: "Search the effective Tool Search catalog.",
description:
"Search the effective Tool Search catalog. Pass an exact result id or name to tool_call; use tool_describe only when you need its input schema.",
parameters: Type.Object({
query: Type.String({ description: "Search query." }),
limit: Type.Optional(Type.Number({ description: "Maximum number of results." })),
@@ -2357,7 +2481,8 @@ export function createToolSearchTools(ctx: ToolSearchToolContext): AnyAgentTool[
{
name: TOOL_DESCRIBE_RAW_TOOL_NAME,
label: "Tool Describe",
description: "Load the full schema and metadata for one search result.",
description:
"Load the full schema and metadata for one search result when its input is not already clear.",
parameters: Type.Object({
id: Type.String({ description: "Tool search result id or tool name." }),
}),
@@ -2367,7 +2492,7 @@ export function createToolSearchTools(ctx: ToolSearchToolContext): AnyAgentTool[
{
name: TOOL_CALL_RAW_TOOL_NAME,
label: "Tool Call",
description: "Call a selected Tool Search catalog entry through OpenClaw.",
description: "Call an exact Tool Search result id or name through OpenClaw.",
parameters: Type.Object({
id: Type.String({ description: "Tool search result id or tool name." }),
args: Type.Optional(