mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
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:
committed by
GitHub
parent
0c06de9e0e
commit
57cc2ca308
@@ -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
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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
@@ -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.",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -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"
|
||||
) {
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user