From 57cc2ca308c1e9762d2617d0b0959c8764eca27e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 16 Jul 2026 20:53:42 -0700 Subject: [PATCH] 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 --- docs/reference/code-mode.md | 27 +++-- docs/tools/tool-search.md | 8 +- src/agents/code-mode-headless.test.ts | 8 +- src/agents/code-mode.test.ts | 37 ++++-- src/agents/code-mode.ts | 35 ++++-- src/agents/code-mode.worker.ts | 4 +- src/agents/tool-search.test.ts | 92 +++++++++++++++ src/agents/tool-search.ts | 155 +++++++++++++++++++++++--- 8 files changed, 315 insertions(+), 51 deletions(-) diff --git a/docs/reference/code-mode.md b/docs/reference/code-mode.md index 8e6f56681e81..d41ee718a603 100644 --- a/docs/reference/code-mode.md +++ b/docs/reference/code-mode.md @@ -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; describe(id: string): Promise; + callValue(id: string, input?: unknown): Promise; call(id: string, input?: unknown): Promise; [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..({ ...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/.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/.d.ts")` and assert the declaration files describe visible MCP tools. 10. In `exec`, call MCP tools through `MCP..({ ...input })` and diff --git a/docs/tools/tool-search.md b/docs/tools/tool-search.md index 04de4f033671..9a01c51d4cd4 100644 --- a/docs/tools/tool-search.md +++ b/docs/tools/tool-search.md @@ -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, { diff --git a/src/agents/code-mode-headless.test.ts b/src/agents/code-mode-headless.test.ts index f7332c1b8d31..f0ac8ec0a79e 100644 --- a/src/agents/code-mode-headless.test.ts +++ b/src/agents/code-mode-headless.test.ts @@ -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, }), diff --git a/src/agents/code-mode.test.ts b/src/agents/code-mode.test.ts index 8d456ad88c51..96f8824c8282 100644 --- a/src/agents/code-mode.test.ts +++ b/src/agents/code-mode.test.ts @@ -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; `, diff --git a/src/agents/code-mode.ts b/src/agents/code-mode.ts index 875417faf2ed..e1bc7abaa04d 100644 --- a/src/agents/code-mode.ts +++ b/src/agents/code-mode.ts @@ -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.", }), ), }), diff --git a/src/agents/code-mode.worker.ts b/src/agents/code-mode.worker.ts index 308c144f3df4..0f142f1adc07 100644 --- a/src/agents/code-mode.worker.ts +++ b/src/agents/code-mode.worker.ts @@ -13,7 +13,7 @@ const require = createRequire(import.meta.url); const QUICKJS_WASM_PATH = require.resolve("quickjs-wasi/quickjs.wasm"); let quickJsWasmModulePromise: Promise | 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" ) { diff --git a/src/agents/tool-search.test.ts b/src/agents/tool-search.test.ts index b769c4d67432..b93bf585dbd3 100644 --- a/src/agents/tool-search.test.ts +++ b/src/agents/tool-search.test.ts @@ -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>>>; 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"); diff --git a/src/agents/tool-search.ts b/src/agents/tool-search.ts index 465d6e86e441..eed448182739 100644 --- a/src/agents/tool-search.ts +++ b/src/agents/tool-search.ts @@ -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(); + 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 { + 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(