diff --git a/docs/reference/code-mode.md b/docs/reference/code-mode.md index d41ee718a603..eb3860d2d542 100644 --- a/docs/reference/code-mode.md +++ b/docs/reference/code-mode.md @@ -39,9 +39,12 @@ identically-named `exec`/`wait` tools. cannot survive the guest bridge. - `exec` evaluates model-generated JavaScript or TypeScript in an isolated QuickJS-WASI worker thread. -- Every catalog-eligible enabled tool (OpenClaw core, plugin, MCP, client) is hidden from - the model prompt and exposed inside the guest program through `ALL_TOOLS` +- Every catalog-eligible enabled tool (OpenClaw core, plugin, MCP, client) is hidden as a + standalone model tool and exposed inside the guest program through `ALL_TOOLS` and `tools`. +- The `exec` description carries a bounded quick index of exact OpenClaw/plugin + catalog ids and compact input hints. It omits descriptions, full schemas, MCP + entries, and overflow entries; guest-side catalog lookup remains the fallback. - Guest code searches the hidden catalog, describes a tool's schema, and calls a tool through the same execution path used by normal agent turns (policy, approvals, hooks, telemetry all still apply). @@ -56,8 +59,9 @@ behavior, or model selection. ## Why use it -- Smaller prompt surface: providers get two control tools and only the few - required direct tools instead of dozens or hundreds of full tool schemas. +- Smaller prompt surface: providers get two control tools, a bounded native-tool + index, and only the few required direct tools instead of dozens or hundreds + of full tool schemas. - Better orchestration: the model can use loops, joins, small transforms, conditional logic, and parallel nested tool calls inside one code cell. - Provider neutral: works for OpenClaw, plugin, MCP, and client tools without @@ -375,7 +379,18 @@ declare function yield_control(reason?: string): Promise; ``` `ALL_TOOLS` is compact metadata for the run-scoped catalog; it does not -contain full schemas by default. +contain full schemas by default. The model-visible `exec` description also +includes a bounded, deterministic subset of exact OpenClaw/plugin ids and +compact input hints so common calls can start without a separate catalog +discovery turn. Descriptions remain deferred so adversarial catalog prose cannot +steer the model. When that index omits a tool, read `ALL_TOOLS` or call +`tools.search(...)` inside the guest program. + +Compact entries describe tool inputs, not result schemas, so the index marks +their result shape as `-> ?` (output unknown). When a workflow needs result +fields, the first `exec` must return the raw +`tools.callValue(...)` result unchanged. Filter or map the observed shape only +in a later `exec` call instead of guessing field names. ```typescript type ToolCatalogEntry = { diff --git a/src/agents/code-mode.test.ts b/src/agents/code-mode.test.ts index 96f8824c8282..b2dcf64212c7 100644 --- a/src/agents/code-mode.test.ts +++ b/src/agents/code-mode.test.ts @@ -395,6 +395,10 @@ describe("Code Mode", () => { 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("Quick-index input hints are not output schemas"); + expect(execTool.description).toContain("never guess result field names"); + expect(execTool.description).toContain("return the raw tool value unchanged"); + expect(execTool.description).toContain("filter or map it only in a later exec"); 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"'); @@ -418,6 +422,72 @@ describe("Code Mode", () => { ); }); + it("primes the exec schema with exact native tool ids and compact inputs", () => { + const { config, catalogRef, tools } = createCodeModeHarness(); + const compacted = applyCodeModeCatalog({ + tools: [ + ...tools, + pluginTool("zeta_tool", "Description stays deferred."), + pluginTool("alpha_tool", "Another deferred description."), + ], + config, + sessionId: "session-code-mode", + sessionKey: "agent:main:main", + runId: "run-code-mode", + catalogRef, + }); + + const description = compacted.tools[0]?.description ?? ""; + expect(description).toContain("descriptions are intentionally deferred"); + expect(description).toContain('- "openclaw:fake-code-mode:alpha_tool" { value?: string } -> ?'); + expect(description).toContain('- "openclaw:fake-code-mode:zeta_tool" { value?: string } -> ?'); + expect(description.indexOf("alpha_tool")).toBeLessThan(description.indexOf("zeta_tool")); + expect(description).not.toContain("Description stays deferred."); + expect(description).not.toContain("Another deferred description."); + }); + + it("keeps a typical 72-tool catalog fully indexed", () => { + const { config, catalogRef, tools } = createCodeModeHarness(); + const catalogTools = Array.from({ length: 72 }, (_, index) => + pluginTool(`tool_${index.toString().padStart(3, "0")}`, "Deferred", "catalog-owner"), + ); + const compacted = applyCodeModeCatalog({ + tools: [...tools, ...catalogTools], + config, + sessionId: "session-code-mode", + sessionKey: "agent:main:main", + runId: "run-code-mode", + catalogRef, + }); + + const description = compacted.tools[0]?.description ?? ""; + expect(description).toContain('"openclaw:catalog-owner:tool_071"'); + expect(description).not.toContain("additional OpenClaw/plugin tools omitted"); + }); + + it("bounds the model-visible native tool index", () => { + const { config, catalogRef, tools } = createCodeModeHarness(); + const pluginId = `fake-${"x".repeat(120)}`; + const catalogTools = Array.from({ length: 100 }, (_, index) => + pluginTool(`fake_${index.toString().padStart(3, "0")}`, "Deferred", pluginId), + ); + const compacted = applyCodeModeCatalog({ + tools: [...tools, ...catalogTools], + config, + sessionId: "session-code-mode", + sessionKey: "agent:main:main", + runId: "run-code-mode", + catalogRef, + }); + + const description = compacted.tools[0]?.description ?? ""; + const indexStart = description.indexOf("OpenClaw/plugin tool quick index"); + const index = indexStart >= 0 ? description.slice(indexStart) : ""; + expect(index.length).toBeLessThanOrEqual(8_000); + expect(index).toContain("additional OpenClaw/plugin tools omitted"); + expect(index).not.toContain("fake_099"); + }); + it("adds registered namespace docs to the model-visible exec schema", () => { registerTestNamespace({ id: "tickets", @@ -472,7 +542,16 @@ describe("Code Mode", () => { const compacted = applyCodeModeCatalog({ tools: [ ...tools, - mcpTool({ name: "github__create_issue", serverName: "github", toolName: "create_issue" }), + pluginTool("fake_noop", "Noop"), + mcpTool({ + name: "github__create_issue", + serverName: "github", + toolName: "create_issue", + parameters: { + type: "object", + properties: { malicious_prompt: { type: "string" } }, + }, + }), ], config, sessionId: "session-code-mode", @@ -484,6 +563,9 @@ describe("Code Mode", () => { const description = compacted.tools[0]?.description ?? ""; expect(description).toContain("API.list(prefix?)"); expect(description).toContain("MCP tools are available only through"); + expect(description).toContain('"openclaw:fake-code-mode:fake_noop"'); + expect(description).not.toContain("github__create_issue"); + expect(description).not.toContain("malicious_prompt"); }); it("validates namespace registrations before exposing globals", () => { diff --git a/src/agents/code-mode.ts b/src/agents/code-mode.ts index e1bc7abaa04d..9fa4247a0a00 100644 --- a/src/agents/code-mode.ts +++ b/src/agents/code-mode.ts @@ -40,6 +40,7 @@ import type { ToolDefinition } from "./sessions/index.js"; import { addClientToolsToToolCatalog, applyToolCatalogCompaction, + compactToolSearchCatalogEntry, TOOL_CALL_RAW_TOOL_NAME, TOOL_DESCRIBE_RAW_TOOL_NAME, TOOL_SEARCH_CODE_MODE_TOOL_NAME, @@ -67,6 +68,7 @@ const DEFAULT_SNAPSHOT_TTL_SECONDS = 900; const DEFAULT_SEARCH_LIMIT = 8; const DEFAULT_MAX_SEARCH_LIMIT = 50; const MAX_ACTIVE_CODE_MODE_RUNS = 64; +const MAX_CODE_MODE_CATALOG_INDEX_CHARS = 8_000; type CodeModeLanguage = "javascript" | "typescript"; @@ -1269,6 +1271,54 @@ function telemetry(runtime: ToolSearchRuntime) { }; } +function renderCodeModeCatalogIndex(lines: readonly string[], total: number): string { + const omitted = total - lines.length; + const footer = + omitted > 0 + ? `${omitted} additional OpenClaw/plugin tools omitted from this prompt index. Use ALL_TOOLS or tools.search inside exec to find them.` + : "Use these exact ids with tools.callValue; use ALL_TOOLS or tools.search inside exec when lookup is ambiguous."; + return [ + "OpenClaw/plugin tool quick index (exact catalog ids and compact input hints; descriptions are intentionally deferred):", + "Each line contains an exact catalog id and compact input hint; `-> ?` means its output schema is unknown.", + "OUTPUT UNKNOWN RULE: the first exec must return that tool's raw value unchanged; filter or map it only in a later exec after observing its shape.", + ...lines, + "", + footer, + ].join("\n"); +} + +function formatCodeModeCatalogIndex(catalog: readonly ToolSearchCatalogEntry[]): string { + const lines = catalog + .filter((entry) => entry.source === "openclaw") + .map((entry) => compactToolSearchCatalogEntry(entry)) + .toSorted((a, b) => a.id.localeCompare(b.id)) + .map((entry) => `- ${JSON.stringify(entry.id)} ${entry.input ?? "unknown"} -> ?`); + if (lines.length === 0) { + return ""; + } + const fullIndex = renderCodeModeCatalogIndex(lines, lines.length); + if (fullIndex.length <= MAX_CODE_MODE_CATALOG_INDEX_CHARS) { + return fullIndex; + } + + // Prompt bytes and ordering must stay stable for provider prompt caches. + // Truncated entries remain discoverable inside the guest through ALL_TOOLS. + let low = 0; + let high = lines.length; + while (low < high) { + const middle = Math.ceil((low + high) / 2); + if ( + renderCodeModeCatalogIndex(lines.slice(0, middle), lines.length).length <= + MAX_CODE_MODE_CATALOG_INDEX_CHARS + ) { + low = middle; + } else { + high = middle - 1; + } + } + return renderCodeModeCatalogIndex(lines.slice(0, low), lines.length); +} + function createCodeModeExecDescription( ctx: CodeModeToolContext, catalog?: readonly ToolSearchCatalogEntry[], @@ -1288,12 +1338,14 @@ function createCodeModeExecDescription( !catalogKnown || namespacePrompt ? " Registered plugin namespaces are available as direct globals and through `namespaces` when their required tools are visible in the run catalog." : ""; + const catalogIndex = catalog ? formatCodeModeCatalogIndex(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`. 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." + + "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`. Quick-index input hints are not output schemas: `output unknown` means never guess result field names. For an unknown output, the first exec must return the raw tool value unchanged with `return await tools.callValue(id, args);`; filter or map it only in a later exec after observing its shape. Prefer one exec invocation only when required result fields are documented: 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.' + - (namespacePrompt ? `\n\n${namespacePrompt}` : "") + (namespacePrompt ? `\n\n${namespacePrompt}` : "") + + (catalogIndex ? `\n\n${catalogIndex}` : "") ); } diff --git a/src/agents/tool-search.ts b/src/agents/tool-search.ts index eed448182739..36a11fa4d9a2 100644 --- a/src/agents/tool-search.ts +++ b/src/agents/tool-search.ts @@ -1261,7 +1261,7 @@ function compactInputHint(parameters: unknown): string { return `{ ${parts.join("; ")}${omitted ? "; ..." : ""} }`; } -function compactEntry(entry: ToolSearchCatalogEntry) { +export function compactToolSearchCatalogEntry(entry: ToolSearchCatalogEntry) { return { id: entry.id, source: entry.source, @@ -1289,7 +1289,9 @@ function formatToolDirectoryIdentifier(value: string | undefined): string | unde return trimmed && TOOL_DIRECTORY_IDENTIFIER_RE.test(trimmed) ? trimmed : undefined; } -function formatToolDirectoryEntry(entry: ReturnType): string | undefined { +function formatToolDirectoryEntry( + entry: ReturnType, +): string | undefined { if (entry.source !== "openclaw") { return undefined; } @@ -1312,7 +1314,9 @@ function renderToolSearchCatalogDirectory(lines: string[], total: number): strin return ["Available deferred-schema tools:", ...lines, "", footer].join("\n"); } -function formatToolSearchCatalogDirectory(entries: Array>): string { +function formatToolSearchCatalogDirectory( + entries: Array>, +): string { if (entries.length === 0) { return "Available deferred-schema tools: none."; } @@ -1648,7 +1652,7 @@ export function estimateToolSchemaDirectoryToolNames(params: { function describeEntry(entry: ToolSearchCatalogEntry) { return { - ...compactEntry(entry), + ...compactToolSearchCatalogEntry(entry), parameters: entry.parameters ?? {}, }; } @@ -1879,18 +1883,20 @@ export class ToolSearchRuntime { .filter((hit) => hit.score > 0) .toSorted((a, b) => b.score - a.score || a.entry.id.localeCompare(b.entry.id)) .slice(0, limit) - .map((hit) => compactEntry(hit.entry)); + .map((hit) => compactToolSearchCatalogEntry(hit.entry)); }; all = (options?: CatalogVisibilityOptions) => { const catalog = resolveCatalog(this.ctx); - return visibleCatalogEntries(catalog, options).map((entry) => compactEntry(entry)); + return visibleCatalogEntries(catalog, options).map((entry) => + compactToolSearchCatalogEntry(entry), + ); }; namespaceEntries = () => { const catalog = resolveCatalog(this.ctx); return catalog.entries.map((entry) => - Object.assign(compactEntry(entry), { + Object.assign(compactToolSearchCatalogEntry(entry), { parameters: entry.parameters ?? {}, }), ); @@ -1984,7 +1990,7 @@ export class ToolSearchRuntime { onUpdate: options?.onUpdate, }); return { - tool: compactEntry(entry), + tool: compactToolSearchCatalogEntry(entry), result, }; };