diff --git a/docs/reference/code-mode.md b/docs/reference/code-mode.md index 6a91751eeaa..af9c0f79b0e 100644 --- a/docs/reference/code-mode.md +++ b/docs/reference/code-mode.md @@ -329,8 +329,12 @@ type CodeModeFailedResult = { }; ``` -`exec` returns `waiting` when the QuickJS VM suspends with resumable state. The -result includes a `runId` for `wait`. +`exec` returns `waiting` when the QuickJS VM suspends with resumable state that +still needs a model-visible continuation. The result includes a `runId` for +`wait`. Namespace bridge calls, including MCP namespace calls, are auto-drained +inside the same `exec`/`wait` call while they are ready, so a compact code block +can inspect `$api()` and call an MCP tool without forcing one model tool call per +namespace await. `exec` returns `completed` only when the guest VM has no pending work and the final value is JSON-compatible after OpenClaw's output adapter runs. @@ -436,9 +440,14 @@ 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. Use the generated `MCP` namespace instead: +functions in code mode. They are exposed only through the generated `MCP` +namespace, which includes TypeScript-style API headers for discovery: ```typescript +const servers = await MCP.$api(); +const githubApi = await MCP.github.$api(); +const createIssueApi = await MCP.github.$api("createIssue", { schema: true }); + const issue = await MCP.github.createIssue({ owner: "openclaw", repo: "openclaw", @@ -446,8 +455,40 @@ const issue = await MCP.github.createIssue({ }); const snapshot = await MCP.chromeDevtools.takeSnapshot({ output: "markdown" }); -const resource = await MCP.docs.resources.read("memo://one"); -const prompt = await MCP.docs.prompts.get("brief", { topic: "release" }); +const resource = await MCP.docs.resources.read({ uri: "memo://one" }); +const prompt = await MCP.docs.prompts.get({ + name: "brief", + arguments: { topic: "release" }, +}); +``` + +`MCP..$api()` returns a compact header inferred from MCP tool metadata: + +```typescript +type McpToolResult = { + content?: unknown[]; + structuredContent?: unknown; + isError?: boolean; + [key: string]: unknown; +}; + +declare namespace MCP.github { + /** Return this TypeScript-style API header. */ + function $api(toolName?: string, options?: { schema?: boolean }): Promise; + + /** + * Create a GitHub issue. + * @param owner Repository owner + * @param repo Repository name + * @param title Issue title + */ + function createIssue(input: { + owner: string; + repo: string; + title: string; + body?: string; + }): Promise; +} ``` The guest runtime must not expose host objects directly. Inputs and outputs cross @@ -493,8 +534,9 @@ run follows this path: 6. Guest calls suspend through the worker bridge, resolve the namespace path on the host, map the call to a declared plugin-owned catalog tool, and execute that tool through `ToolSearchRuntime.call`. -7. `wait` resumes the same namespace runtime when a code-mode run suspended on - nested tool work. +7. OpenClaw auto-drains ready namespace bridge calls inside the active + `exec`/`wait` tool call. If namespace work is still pending at the timeout or + the guest yields explicitly, `wait` resumes the same namespace runtime later. 8. Plugin rollback or uninstall calls `clearCodeModeNamespacesForPlugin(pluginId)` so stale globals do not survive a failed plugin load. @@ -701,9 +743,10 @@ This prevents recursion and keeps the model-facing contract narrow. 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 `tools.call(...)` bridge rejects MCP entries; -the generated `MCP..(...)` namespace resolves back to the exact -catalog id and then dispatches through the same executor path. +tool execution. The guest-facing `ALL_TOOLS`, `tools.search(...)`, +`tools.describe(...)`, and `tools.call(...)` views omit MCP entries. The +generated `MCP..({ ...input })` namespace resolves back to the +exact catalog id and then dispatches through the same executor path. ## Tool Search interaction @@ -716,8 +759,9 @@ When `tools.codeMode.enabled` is true and code mode activates: or `tool_call` as model-visible tools. - The same cataloging idea moves inside the guest runtime. - The guest runtime receives compact `ALL_TOOLS` metadata and search, describe, - and call helpers. -- MCP calls use the generated `MCP` namespace instead of `tools.call(...)`. + and call helpers for non-MCP tools. +- MCP calls use the generated `MCP` namespace and its `$api()` headers instead + of `tools.call(...)`. - Nested calls dispatch through the same OpenClaw executor path that Tool Search uses. @@ -934,11 +978,13 @@ Code mode coverage should prove: active for the run - raw no-tool runs, `disableTools`, and empty allowlists do not trigger code-mode payload enforcement -- all effective tools appear in `ALL_TOOLS` +- all effective non-MCP tools appear in `ALL_TOOLS` - denied tools do not appear in `ALL_TOOLS` - `tools.search`, `tools.describe`, and `tools.call` work for OpenClaw tools -- MCP namespace calls work for visible MCP tools and direct MCP `tools.call` - attempts fail closed +- MCP namespace `$api()` returns TypeScript-style headers inferred from MCP + schemas +- MCP namespace calls work for visible MCP tools with one object input, while + direct MCP catalog entries are absent from `tools.*` - Tool Search control tools are hidden from both the model surface and the hidden catalog - nested calls preserve approval and hook behavior @@ -968,14 +1014,16 @@ Run these as integration or end-to-end tests when changing the runtime: 7. In `exec`, read `ALL_TOOLS` and assert the effective test tools are present. 8. In `exec`, call OpenClaw/plugin/client tools through `tools.search`, `tools.describe`, and `tools.call`. -9. In `exec`, call MCP tools through `MCP..(...)` and assert direct - MCP `tools.call(...)` attempts fail. -10. Assert denied tools are absent and cannot be called by guessed id. -11. Start a nested tool call that resolves after `exec` returns `waiting`. -12. Call `wait` and assert the restored VM receives the tool result. -13. Assert the final answer contains output produced after restore. -14. Assert timeout, abort, and snapshot expiry clean up runtime state. -15. Export trajectory and assert nested calls are visible under the parent +9. In `exec`, call `MCP.$api()` and `MCP..$api()` and assert the headers + describe visible MCP tools. +10. In `exec`, call MCP tools through `MCP..({ ...input })` and + assert direct MCP catalog entries are absent from `ALL_TOOLS` and `tools.*`. +11. Assert denied tools are absent and cannot be called by guessed id. +12. Start a nested tool call that resolves after `exec` returns `waiting`. +13. Call `wait` and assert the restored VM receives the tool result. +14. Assert the final answer contains output produced after restore. +15. Assert timeout, abort, and snapshot expiry clean up runtime state. +16. Export trajectory and assert nested calls are visible under the parent code-mode call. Docs-only changes to this page should still run `pnpm check:docs`. diff --git a/extensions/qa-lab/src/providers/mock-openai/server.ts b/extensions/qa-lab/src/providers/mock-openai/server.ts index 6dfe0e2d270..fcd677995a6 100644 --- a/extensions/qa-lab/src/providers/mock-openai/server.ts +++ b/extensions/qa-lab/src/providers/mock-openai/server.ts @@ -174,6 +174,7 @@ const QA_SKILL_WORKSHOP_REVIEW_PROMPT_RE = /Review transcript for durable skill const QA_RELEASE_AUDIT_PROMPT_RE = /release readiness audit for the small project/i; const QA_TOOL_SEARCH_PROMPT_RE = /tool search qa check/i; const QA_TOOL_SEARCH_FAILURE_PROMPT_RE = /tool search qa failure/i; +const QA_MCP_CODE_MODE_PROMPT_RE = /mcp code mode qa check/i; type MockScenarioState = { subagentFanoutPhase: number; @@ -1806,6 +1807,38 @@ async function buildResponsesPayload( return buildToolCallEventsWithArgs(targetTool, plannedArgs); } } + if (QA_MCP_CODE_MODE_PROMPT_RE.test(allInputText)) { + if (!toolOutput && hasDeclaredTool(body, "exec")) { + return buildToolCallEventsWithArgs("exec", { + language: "javascript", + code: [ + "const rootApi = await MCP.$api();", + 'const api = await MCP.fixture.$api("lookupNote", { schema: true });', + 'const result = await MCP.fixture.lookupNote({ id: "alpha" });', + "return {", + ' marker: "MCP_CODE_MODE_TOOL_RESULT",', + " rootServers: rootApi.servers,", + " headerHasLookup: api.header.includes('function lookupNote'),", + " schemaKeys: Object.keys(api.schemas),", + " resultText: result.content?.[0]?.text,", + " allHasMcp: ALL_TOOLS.some((tool) => tool.source === 'mcp'),", + "};", + ].join("\n"), + }); + } + if ( + toolJson?.status === "waiting" && + typeof toolJson.runId === "string" && + hasDeclaredTool(body, "wait") + ) { + return buildToolCallEventsWithArgs("wait", { runId: toolJson.runId }); + } + if (/MCP_CODE_MODE_TOOL_RESULT|fixture-note-alpha/.test(toolOutput)) { + return buildAssistantEvents( + "MCP_CODE_MODE_OK unclear=none improvement=virtual-header-files-would-avoid-the-first-api-call", + ); + } + } if ( allInputText.includes(QA_SUBAGENT_DIRECT_FALLBACK_MARKER) && /Internal task completion event/i.test(allInputText) diff --git a/scripts/mcp-code-mode-gateway-e2e.ts b/scripts/mcp-code-mode-gateway-e2e.ts new file mode 100644 index 00000000000..dbbf60a108a --- /dev/null +++ b/scripts/mcp-code-mode-gateway-e2e.ts @@ -0,0 +1,358 @@ +import fs from "node:fs/promises"; +import { createRequire } from "node:module"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { setTimeout as setNodeTimeout, clearTimeout as clearNodeTimeout } from "node:timers"; +import { pathToFileURL } from "node:url"; +import { startQaMockOpenAiServer } from "../extensions/qa-lab/src/providers/mock-openai/server.js"; +import { stageQaMockAuthProfiles } from "../extensions/qa-lab/src/providers/shared/mock-auth.js"; +import { buildQaGatewayConfig } from "../extensions/qa-lab/src/qa-gateway-config.js"; +import { resetConfigRuntimeState } from "../src/config/config.js"; +import { startGatewayServer } from "../src/gateway/server.js"; +import { readBoundedResponseText } from "./lib/bounded-response.ts"; + +const require = createRequire(import.meta.url); + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +async function freePort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + server.close((error) => (error ? reject(error) : resolve(port))); + }); + }); +} + +async function fetchJson(url: string, init: RequestInit = {}): Promise { + const timeoutMs = 180_000; + const controller = new AbortController(); + let timeout: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeout = setNodeTimeout(() => { + const error = Object.assign(new Error(`HTTP request to ${url} timed out`), { + code: "ETIMEDOUT", + }); + controller.abort(error); + reject(error); + }, timeoutMs); + }); + try { + const response = await Promise.race([ + fetch(url, { ...init, signal: controller.signal }), + timeoutPromise, + ]); + const text = await readBoundedResponseText(response, url, 1024 * 1024, { + createTooLargeError(message) { + return Object.assign(new Error(message), { code: "ETOOBIG" }); + }, + formatTooLargeMessage(targetUrl, byteLimit) { + return `HTTP response from ${targetUrl} exceeded ${byteLimit} bytes`; + }, + timeoutPromise, + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status} from ${url}: ${text}`); + } + return text ? JSON.parse(text) : {}; + } finally { + if (timeout) { + clearNodeTimeout(timeout); + } + } +} + +function outputText(response: unknown): string { + const output = (response as { output?: Array<{ type?: unknown; content?: unknown }> }).output; + if (!Array.isArray(output)) { + return ""; + } + return output + .flatMap((item) => { + if (item.type !== "message" || !Array.isArray(item.content)) { + return []; + } + return item.content.flatMap((piece) => { + if (!piece || typeof piece !== "object") { + return []; + } + const record = piece as { text?: unknown }; + return typeof record.text === "string" ? [record.text] : []; + }); + }) + .join("\n"); +} + +function countOccurrences(haystack: string, needle: string): number { + if (!needle) { + return 0; + } + let count = 0; + let offset = 0; + while (true) { + const next = haystack.indexOf(needle, offset); + if (next < 0) { + return count; + } + count += 1; + offset = next + needle.length; + } +} + +async function readSessionLogMentions(stateDir: string): Promise> { + const sessionsDir = path.join(stateDir, "agents", "qa", "sessions"); + const mentions = { + apiCall: 0, + mcpNamespace: 0, + mcpTool: 0, + toolSearchPollution: 0, + }; + const files = await fs.readdir(sessionsDir).catch(() => []); + for (const file of files.filter((candidate) => candidate.endsWith(".jsonl"))) { + const raw = await fs.readFile(path.join(sessionsDir, file), "utf8").catch(() => ""); + mentions.apiCall += countOccurrences(raw, "MCP.$api"); + mentions.mcpNamespace += countOccurrences(raw, "MCP.fixture"); + mentions.mcpTool += countOccurrences(raw, "fixture__lookup_note"); + mentions.toolSearchPollution += countOccurrences(raw, 'tools.search("lookup note"'); + } + return mentions; +} + +async function writeProbeMcpServer(serverPath: string) { + const sdkMcpServerPath = require.resolve("@modelcontextprotocol/sdk/server/mcp.js"); + const sdkStdioServerPath = require.resolve("@modelcontextprotocol/sdk/server/stdio.js"); + const zodPath = require.resolve("zod"); + await fs.mkdir(path.dirname(serverPath), { recursive: true }); + await fs.writeFile( + serverPath, + `#!/usr/bin/env node +import { McpServer } from ${JSON.stringify(sdkMcpServerPath)}; +import { StdioServerTransport } from ${JSON.stringify(sdkStdioServerPath)}; +import { z } from ${JSON.stringify(zodPath)}; + +const notes = new Map([ + ["alpha", "fixture-note-alpha"], + ["beta", "fixture-note-beta"], +]); +const server = new McpServer({ name: "code-mode-fixture", version: "1.0.0" }); + +server.tool( + "lookup_note", + "Look up one read-only fixture note by id.", + { + id: z.string().describe("Fixture note id to look up."), + }, + async ({ id }) => ({ + content: [{ type: "text", text: notes.get(id) ?? "missing-note" }], + }), +); + +await server.connect(new StdioServerTransport()); +`, + { encoding: "utf8", mode: 0o755 }, + ); +} + +async function writeConfig(params: { + configPath: string; + stateDir: string; + workspaceDir: string; + gatewayPort: number; + providerBaseUrl: string; + serverPath: string; +}) { + let cfg = buildQaGatewayConfig({ + bind: "loopback", + gatewayPort: params.gatewayPort, + gatewayToken: "mcp-code-mode-e2e", + providerBaseUrl: `${params.providerBaseUrl}/v1`, + workspaceDir: params.workspaceDir, + controlUiEnabled: false, + providerMode: "mock-openai", + }); + cfg = { + ...cfg, + plugins: { + ...cfg.plugins, + slots: { + ...cfg.plugins?.slots, + memory: "none", + }, + }, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + memorySearch: { + ...cfg.agents?.defaults?.memorySearch, + enabled: false, + sync: { + ...cfg.agents?.defaults?.memorySearch?.sync, + onSearch: false, + onSessionStart: false, + watch: false, + }, + }, + }, + }, + tools: { + ...cfg.tools, + alsoAllow: [...new Set([...(cfg.tools?.alsoAllow ?? []), "bundle-mcp"])], + codeMode: { + enabled: true, + timeoutMs: 20_000, + maxPendingToolCalls: 16, + }, + }, + mcp: { + servers: { + fixture: { + command: "node", + args: [params.serverPath], + cwd: path.dirname(params.serverPath), + connectionTimeoutMs: 30_000, + }, + }, + }, + gateway: { + ...cfg.gateway, + http: { + endpoints: { + responses: { + enabled: true, + }, + }, + }, + }, + }; + cfg = await stageQaMockAuthProfiles({ + cfg, + stateDir: params.stateDir, + agentIds: ["qa"], + providers: ["mock-openai", "openai", "anthropic"], + }); + await fs.mkdir(path.dirname(params.configPath), { recursive: true }); + await fs.writeFile(params.configPath, `${JSON.stringify(cfg, null, 2)}\n`, "utf8"); +} + +export async function main() { + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mcp-code-mode-")); + const keep = process.env.OPENCLAW_MCP_CODE_MODE_GATEWAY_E2E_KEEP === "1"; + let provider: Awaited> | undefined; + let server: Awaited> | undefined; + try { + provider = await startQaMockOpenAiServer(); + const stateDir = path.join(rootDir, "state"); + const workspaceDir = path.join(rootDir, "workspace"); + const serverPath = path.join(rootDir, "mcp", "fixture-server.mjs"); + const configPath = path.join(stateDir, "openclaw.json"); + const gatewayPort = await freePort(); + await fs.mkdir(workspaceDir, { recursive: true }); + await writeProbeMcpServer(serverPath); + await writeConfig({ + configPath, + stateDir, + workspaceDir, + gatewayPort, + providerBaseUrl: provider.baseUrl, + serverPath, + }); + + process.env.OPENCLAW_STATE_DIR = stateDir; + process.env.OPENCLAW_CONFIG_PATH = configPath; + process.env.OPENCLAW_TEST_FAST = "1"; + resetConfigRuntimeState(); + + server = await startGatewayServer(gatewayPort, { + host: "127.0.0.1", + auth: { mode: "none" }, + controlUiEnabled: false, + openResponsesEnabled: true, + }); + + const beforeRequests = (await fetchJson(`${provider.baseUrl}/debug/requests`)) as unknown[]; + const response = await fetchJson(`http://127.0.0.1:${gatewayPort}/v1/responses`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-openclaw-scopes": "operator.write", + "x-openclaw-agent": "qa", + }, + body: JSON.stringify({ + model: "openclaw/qa", + input: [ + { + type: "message", + role: "user", + content: [ + { + type: "input_text", + text: "mcp code mode qa check: inspect the MCP typed API, call the fixture lookup_note tool for alpha, and say what was unclear.", + }, + ], + }, + ], + max_output_tokens: 256, + stream: false, + }), + }); + const requests = (await fetchJson(`${provider.baseUrl}/debug/requests`)) as Array<{ + raw?: string; + body?: { tools?: unknown[] }; + plannedToolName?: string; + }>; + const laneRequests = requests.slice(beforeRequests.length); + const firstRequest = laneRequests[0] ?? {}; + const finalText = outputText(response); + const mentions = await readSessionLogMentions(stateDir); + const plannedTools = laneRequests + .map((request) => request.plannedToolName) + .filter((name): name is string => typeof name === "string"); + + assert(finalText.includes("MCP_CODE_MODE_OK"), "agent did not complete MCP code-mode turn"); + assert(plannedTools.includes("exec"), "agent did not call code-mode exec"); + assert(mentions.apiCall > 0 && mentions.mcpNamespace > 0, "session log lacks MCP API usage"); + assert(mentions.mcpTool > 0, "session log lacks materialized MCP tool call"); + assert(mentions.toolSearchPollution === 0, "MCP lookup leaked through tools.search"); + + const summary = { + ok: true, + rootDir, + stateDir, + gatewayUrl: `http://127.0.0.1:${gatewayPort}`, + finalText, + providerRequestCount: laneRequests.length, + providerDeclaredToolCount: Array.isArray(firstRequest.body?.tools) + ? firstRequest.body.tools.length + : 0, + providerRawBytes: typeof firstRequest.raw === "string" ? firstRequest.raw.length : 0, + plannedTools, + sessionLogMentions: mentions, + }; + process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); + } finally { + await server?.close({ reason: "mcp code-mode gateway e2e complete" }); + await provider?.stop(); + resetConfigRuntimeState(); + delete process.env.OPENCLAW_STATE_DIR; + delete process.env.OPENCLAW_CONFIG_PATH; + delete process.env.OPENCLAW_TEST_FAST; + if (!keep) { + await fs.rm(rootDir, { recursive: true, force: true }); + } + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/src/agents/code-mode-namespaces.ts b/src/agents/code-mode-namespaces.ts index 23d7178175a..a6f6cd5648a 100644 --- a/src/agents/code-mode-namespaces.ts +++ b/src/agents/code-mode-namespaces.ts @@ -47,6 +47,7 @@ export type CodeModeNamespaceToolCall = { readonly [CODE_MODE_NAMESPACE_TOOL_CALL]: true; readonly toolName: string; readonly catalogId?: string; + readonly local?: boolean; readonly input?: CodeModeNamespaceToolInputMapper; }; @@ -90,6 +91,7 @@ type CodeModeNamespaceCatalogEntry = { source?: string; name: string; sourceName?: string; + description?: string; parameters?: unknown; mcp?: { serverName: string; @@ -189,6 +191,22 @@ function createCodeModeNamespaceCatalogTool( }; } +function createCodeModeNamespaceLocalFunction( + toolName: string, + input: CodeModeNamespaceToolInputMapper, +): CodeModeNamespaceToolCall { + const normalizedToolName = toolName.trim(); + if (!normalizedToolName) { + throw new Error("Code mode namespace local function name must be non-empty."); + } + return { + [CODE_MODE_NAMESPACE_TOOL_CALL]: true, + toolName: normalizedToolName, + local: true, + input, + }; +} + function isCodeModeNamespaceToolCall(value: unknown): value is CodeModeNamespaceToolCall { const record = isRecord(value) ? (value as Record) : undefined; return ( @@ -342,6 +360,12 @@ function readSchemaProperties(schema: unknown): Record { return isRecord(record?.properties) ? record.properties : {}; } +function readSchemaString(schema: unknown, key: string): string | undefined { + const record = readSchemaRecord(schema); + const value = record?.[key]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + function readRequiredKeys(schema: unknown): string[] { const record = readSchemaRecord(schema); return Array.isArray(record?.required) @@ -370,20 +394,15 @@ function applySchemaDefaults( } function mapMcpNamespaceInput(schema: unknown, args: unknown[]): unknown { - const orderedKeys = orderedSchemaKeys(schema); - const [firstArg, ...restArgs] = args; - const recordInput = isRecord(firstArg) && restArgs.length === 0 ? { ...firstArg } : undefined; - const result: Record = recordInput ?? {}; - const positional = recordInput ? [] : args; - if (positional.length > orderedKeys.length) { - throw new Error("Too many positional arguments for MCP namespace tool."); + if (args.length > 1) { + throw new Error("MCP namespace tools accept one object argument."); + } + const firstArg = args[0]; + const result: Record = + firstArg === undefined ? {} : isRecord(firstArg) ? { ...firstArg } : {}; + if (firstArg !== undefined && !isRecord(firstArg)) { + throw new Error("MCP namespace tools accept one object argument."); } - positional.forEach((value, index) => { - const key = orderedKeys[index]; - if (key) { - result[key] = value; - } - }); const withDefaults = applySchemaDefaults(schema, result); const missing = readRequiredKeys(schema).filter((key) => withDefaults[key] === undefined); if (missing.length > 0) { @@ -394,6 +413,300 @@ function mapMcpNamespaceInput(schema: unknown, args: unknown[]): unknown { return withDefaults; } +function escapeDocComment(value: string): string { + return value.replace(/\*\//gu, "* /").trim(); +} + +function indent(lines: string[], prefix: string): string[] { + return lines.map((line) => `${prefix}${line}`); +} + +function renderDocComment( + summary: string | undefined, + params: readonly McpApiParamDoc[], +): string[] { + const lines: string[] = []; + const docLines = normalizeDocLines(summary); + if (docLines.length === 0 && params.length === 0) { + return lines; + } + lines.push("/**"); + for (const line of docLines) { + lines.push(` * ${escapeDocComment(line)}`); + } + if (docLines.length > 0 && params.length > 0) { + lines.push(" *"); + } + for (const param of params) { + const description = collapseDocText(param.description); + if (description) { + lines.push( + ` * @param ${param.name}${param.required ? "" : "?"} ${escapeDocComment(description)}`, + ); + } + } + lines.push(" */"); + return lines; +} + +function normalizeDocLines(value: string | undefined): string[] { + if (!value) { + return []; + } + return value + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean) + .slice(0, 12); +} + +function collapseDocText(value: string | undefined): string { + return normalizeDocLines(value).join(" "); +} + +function schemaType(schema: unknown): string { + const record = readSchemaRecord(schema); + if (!record) { + return "unknown"; + } + const enumValues = Array.isArray(record.enum) + ? record.enum.filter( + (entry): entry is string | number | boolean => + typeof entry === "string" || typeof entry === "number" || typeof entry === "boolean", + ) + : []; + if (enumValues.length > 0 && enumValues.length <= 16) { + return enumValues.map((entry) => JSON.stringify(entry)).join(" | "); + } + const oneOf = Array.isArray(record.oneOf) ? record.oneOf : undefined; + const anyOf = Array.isArray(record.anyOf) ? record.anyOf : undefined; + const union = oneOf ?? anyOf; + if (union && union.length > 0 && union.length <= 8) { + return union.map((entry) => schemaType(entry)).join(" | "); + } + const type = record.type; + if (Array.isArray(type)) { + return type.map((entry) => schemaType({ ...record, type: entry })).join(" | "); + } + switch (type) { + case "string": + return "string"; + case "integer": + case "number": + return "number"; + case "boolean": + return "boolean"; + case "array": + return `${schemaType(record.items)}[]`; + case "object": + return renderInlineObjectType(record); + case "null": + return "null"; + default: + return Object.keys(readSchemaProperties(schema)).length > 0 + ? renderInlineObjectType(record) + : "unknown"; + } +} + +function tsPropertyName(name: string): string { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(name) ? name : JSON.stringify(name); +} + +function renderInlineObjectType(schema: unknown): string { + const properties = readSchemaProperties(schema); + const keys = Object.keys(properties); + if (keys.length === 0) { + return "Record"; + } + const required = new Set(readRequiredKeys(schema)); + return `{ ${keys + .map( + (key) => + `${tsPropertyName(key)}${required.has(key) ? "" : "?"}: ${schemaType(properties[key])}`, + ) + .join("; ")} }`; +} + +type McpApiParamDoc = { + name: string; + required: boolean; + type: string; + description?: string; + defaultValue?: unknown; +}; + +type McpApiToolDoc = { + method: string; + path: string[]; + mcpTool: string; + operation: NonNullable["operation"]; + description?: string; + parameters: unknown; + params: McpApiParamDoc[]; +}; + +type McpApiServerDoc = { + identifier: string; + serverName: string; + tools: McpApiToolDoc[]; +}; + +function buildMcpParamDocs(schema: unknown): McpApiParamDoc[] { + const required = new Set(readRequiredKeys(schema)); + return orderedSchemaKeys(schema).map((key) => { + const descriptor = readSchemaProperties(schema)[key]; + const doc: McpApiParamDoc = { + name: key, + required: required.has(key), + type: schemaType(descriptor), + }; + const description = readSchemaString(descriptor, "description"); + if (description) { + doc.description = description; + } + if (isRecord(descriptor) && "default" in descriptor) { + doc.defaultValue = descriptor.default; + } + return doc; + }); +} + +function renderMcpInputType(params: readonly McpApiParamDoc[]): string[] { + if (params.length === 0) { + return ["input?: Record"]; + } + const lines = ["input: {"]; + for (const param of params) { + if (param.description || param.defaultValue !== undefined) { + const description = collapseDocText(param.description); + const suffix = + param.defaultValue === undefined ? "" : ` Default: ${JSON.stringify(param.defaultValue)}.`; + lines.push(` /** ${escapeDocComment(`${description}${suffix}`.trim())} */`); + } + lines.push(` ${tsPropertyName(param.name)}${param.required ? "" : "?"}: ${param.type};`); + } + lines.push("}"); + return lines; +} + +function renderMcpToolSignature( + tool: McpApiToolDoc, + functionName = tool.path.at(-1) ?? tool.method, +): string[] { + const lines = renderDocComment(tool.description, tool.params); + lines.push(`function ${functionName}(`); + lines.push(...indent(renderMcpInputType(tool.params), " ")); + lines.push("): Promise;"); + return lines; +} + +function renderMcpServerHeader(server: McpApiServerDoc, tools: readonly McpApiToolDoc[]): string { + const lines = [ + "type McpApiHeader = { header: string; tools?: unknown[]; schemas?: Record };", + "", + "type McpToolResult = {", + " content?: unknown[];", + " structuredContent?: unknown;", + " isError?: boolean;", + " [key: string]: unknown;", + "};", + "", + `declare namespace MCP.${server.identifier} {`, + " /** Return this TypeScript-style API header. */", + " function $api(toolName?: string, options?: { schema?: boolean }): Promise;", + ]; + const topLevelTools = tools.filter((tool) => tool.path.length === 1); + const nestedTools = tools.filter((tool) => tool.path.length > 1); + for (const tool of topLevelTools) { + lines.push(""); + lines.push(...indent(renderMcpToolSignature(tool), " ")); + } + const nestedGroups = new Map(); + for (const tool of nestedTools) { + const groupName = tool.path[0] ?? "tools"; + nestedGroups.set(groupName, [...(nestedGroups.get(groupName) ?? []), tool]); + } + for (const [groupName, groupTools] of [...nestedGroups.entries()].toSorted((a, b) => + a[0].localeCompare(b[0]), + )) { + lines.push(""); + lines.push(` namespace ${groupName} {`); + for (const tool of groupTools) { + lines.push(""); + lines.push(...indent(renderMcpToolSignature(tool, tool.path.at(-1) ?? tool.method), " ")); + } + lines.push(" }"); + } + lines.push("}"); + return lines.join("\n"); +} + +function renderMcpRootHeader(servers: readonly McpApiServerDoc[]): string { + return [ + "type McpApiHeader = { header: string; servers?: unknown[] };", + "", + "declare const MCP: {", + " /** List visible MCP servers and request server-specific headers. */", + " $api(): Promise;", + ...servers.map((server) => ` readonly ${server.identifier}: typeof MCP.${server.identifier};`), + "};", + ].join("\n"); +} + +function buildMcpApiResponse(params: { + servers: readonly McpApiServerDoc[]; + server?: McpApiServerDoc; + args: unknown[]; +}) { + const [selector, options] = params.args; + const includeSchema = isRecord(options) && options.schema === true; + if (!params.server) { + return { + kind: "mcp_api", + scope: "root", + header: renderMcpRootHeader(params.servers), + servers: params.servers.map((server) => ({ + identifier: server.identifier, + serverName: server.serverName, + toolCount: server.tools.length, + })), + note: "Call MCP..$api() for a TypeScript-style header, then call tools with one object argument matching the shown input type.", + }; + } + const selected = + typeof selector === "string" && selector.trim() + ? params.server.tools.filter( + (tool) => + tool.method === selector.trim() || + tool.path.join(".") === selector.trim() || + tool.mcpTool === selector.trim(), + ) + : params.server.tools; + return { + kind: "mcp_api", + scope: selected.length === 1 ? "tool" : "server", + server: { + identifier: params.server.identifier, + serverName: params.server.serverName, + }, + header: renderMcpServerHeader(params.server, selected), + tools: selected.map((tool) => ({ + method: tool.method, + path: tool.path, + mcpTool: tool.mcpTool, + operation: tool.operation, + description: tool.description, + })), + ...(includeSchema + ? { + schemas: Object.fromEntries(selected.map((tool) => [tool.method, tool.parameters])), + } + : {}), + note: "Call MCP tools with one object argument, for example MCP.server.tool({ requiredField: value }).", + }; +} + function scopeAtPath( root: CodeModeNamespaceScope, path: readonly string[], @@ -420,7 +733,7 @@ function toolIdentifiersForServer( if (existing) { return existing; } - const created = new Set(["resources", "prompts"]); + const created = new Set(["$api", "resources", "prompts"]); usedToolIdentifiers.set(serverIdentifier, created); return created; } @@ -446,6 +759,7 @@ function createMcpNamespaceScope( } const usedToolIdentifiers = new Map>(); const root = Object.create(null) as CodeModeNamespaceScope; + const serverDocs = new Map(); for (const entry of mcpEntries.toSorted((a, b) => (a.id ?? "").localeCompare(b.id ?? ""))) { const mcp = entry.mcp; if (!mcp || !entry.id) { @@ -455,6 +769,11 @@ function createMcpNamespaceScope( serverNames.get(mcp.safeServerName) ?? uniqueIdentifier("server", usedServerIdentifiers); const serverScope = scopeAtPath(root, [serverIdentifier]); serverScope.$serverName = mcp.serverName; + let serverDoc = serverDocs.get(serverIdentifier); + if (!serverDoc) { + serverDoc = { identifier: serverIdentifier, serverName: mcp.serverName, tools: [] }; + serverDocs.set(serverIdentifier, serverDoc); + } const path = mcp.operation === "resources_list" ? ["resources", "list"] @@ -476,6 +795,29 @@ function createMcpNamespaceScope( entry.name, (args) => mapMcpNamespaceInput(entry.parameters, args), ); + serverDoc.tools.push({ + method: path.join("."), + path, + mcpTool: mcp.toolName, + operation: mcp.operation, + description: entry.description, + parameters: entry.parameters, + params: buildMcpParamDocs(entry.parameters), + }); + } + const docs = [...serverDocs.values()].map((server) => + Object.assign({}, server, { + tools: server.tools.toSorted((a, b) => a.method.localeCompare(b.method)), + }), + ); + root.$api = createCodeModeNamespaceLocalFunction("$api", (args) => + buildMcpApiResponse({ servers: docs, args }), + ); + for (const server of docs) { + const serverScope = scopeAtPath(root, [server.identifier]); + serverScope.$api = createCodeModeNamespaceLocalFunction("$api", (args) => + buildMcpApiResponse({ servers: docs, server, args }), + ); } return root; } @@ -515,13 +857,17 @@ function describeMcpNamespaceForPrompt( if (!scope) { return []; } - const servers = Object.keys(scope).toSorted(); + const servers = Object.entries(scope) + .filter(([, value]) => isRecord(value) && typeof value.$serverName === "string") + .map(([key]) => key) + .toSorted(); if (servers.length === 0) { return []; } return [ "- MCP: MCP server tools grouped by server.", - `Use MCP..(args) for MCP tools; visible servers: ${servers.join(", ")}.`, + `Use MCP.$api() or MCP..$api() to inspect TypeScript-style API headers; visible servers: ${servers.join(", ")}.`, + "Call MCP tools as MCP..({ ...input }) with one object argument matching the header.", ]; } @@ -714,10 +1060,13 @@ export async function createCodeModeNamespaceRuntime( if (!isCodeModeNamespaceToolCall(target)) { throw new Error(`Code mode namespace path is not callable: ${path.join(".")}`); } + const input = target.input ? await target.input(args) : (args[0] ?? {}); + if (target.local) { + return toJsonSafe(input); + } if (!target.catalogId && !entry.registration.requiredToolNames.includes(target.toolName)) { throw new Error(`Code mode namespace path targets undeclared tool: ${target.toolName}`); } - const input = target.input ? await target.input(args) : (args[0] ?? {}); return toJsonSafe( await executeTool({ pluginId: entry.registration.pluginId, diff --git a/src/agents/code-mode.test.ts b/src/agents/code-mode.test.ts index bbeecf7374e..b8aa2679986 100644 --- a/src/agents/code-mode.test.ts +++ b/src/agents/code-mode.test.ts @@ -784,8 +784,8 @@ describe("Code Mode", () => { type: "object", properties: { owner: { type: "string" }, - repo: { type: "string" }, - title: { type: "string" }, + repo: { type: "string", description: "Repository name" }, + title: { type: "string", description: "Issue title\nShown in tracker" }, body: { type: "string", default: "" }, }, required: ["owner", "repo", "title"], @@ -807,16 +807,42 @@ describe("Code Mode", () => { execTool: codeModeTools[0], waitTool: codeModeTools[1], code: ` - const created = await MCP.github.createIssue("openclaw", "openclaw", "Ship it"); + const rootApi = await MCP.$api(); + const api = await MCP.github.$api("createIssue", { schema: true }); + const created = await MCP.github.createIssue({ + owner: "openclaw", + repo: "openclaw", + title: "Ship it", + }); const createdPayload = JSON.parse(created.content[0].text); + const searchHits = await tools.search("github create issue", { limit: 5 }); + const allHasMcp = ALL_TOOLS.some((tool) => tool.source === "mcp"); let directCall; + let directDescribe; try { - await tools.github__create_issue({ owner: "x", repo: "y", title: "blocked" }); + await tools.describe("github__create_issue"); + directDescribe = "unexpected"; + } catch (error) { + directDescribe = error.message; + } + try { + await tools.call("github__create_issue", { owner: "x", repo: "y", title: "blocked" }); directCall = "unexpected"; } catch (error) { directCall = error.message; } - return { createdPayload, createdDetails: created.details, directCall, hasMcp: "MCP" in namespaces }; + return { + apiHeader: api.header, + apiSchemaTitle: api.schemas.createIssue.type, + rootServers: rootApi.servers, + createdPayload, + createdDetails: created.details, + searchHits, + allHasMcp, + directDescribe, + directCall, + hasMcp: "MCP" in namespaces, + }; `, }); @@ -842,9 +868,19 @@ describe("Code Mode", () => { body: "", }, }, - directCall: "MCP tools are available in code mode only through the MCP namespace.", + searchHits: [], + allHasMcp: false, + directDescribe: "Unknown tool id: github__create_issue", + directCall: "Unknown tool id: github__create_issue", hasMcp: true, + apiSchemaTitle: "object", + apiHeader: expect.stringContaining("function createIssue("), + rootServers: [{ identifier: "github", serverName: "github", toolCount: 1 }], }); + const value = details.value as { apiHeader: string }; + expect(value.apiHeader).toContain("@param title Issue title Shown in tracker"); + expect(value.apiHeader).not.toContain("@param title Issue title\n"); + expect(value.apiHeader).toContain("title: string;"); expect(githubCreate.execute).toHaveBeenCalledTimes(1); }); @@ -888,9 +924,10 @@ describe("Code Mode", () => { execTool: codeModeTools[0], waitTool: codeModeTools[1], code: ` - const resource = await MCP.docs.resources.read("memo://one"); - const prompt = await MCP.docs.prompts.get("brief", { topic: "mcp" }); - return { resource: resource.details, prompt: prompt.details }; + const api = await MCP.docs.$api(); + const resource = await MCP.docs.resources.read({ uri: "memo://one" }); + const prompt = await MCP.docs.prompts.get({ name: "brief", arguments: { topic: "mcp" } }); + return { header: api.header, resource: resource.details, prompt: prompt.details }; `, }); @@ -906,6 +943,7 @@ describe("Code Mode", () => { toolName: "prompts_get", input: { name: "brief", arguments: { topic: "mcp" } }, }, + header: expect.stringContaining("namespace resources"), }); }); @@ -933,7 +971,7 @@ describe("Code Mode", () => { const details = await runUntilCompleted({ execTool: codeModeTools[0], waitTool: codeModeTools[1], - code: 'return (await MCP.constructor2.prototype2("safe")).details;', + code: 'return (await MCP.constructor2.prototype2({ value: "safe" })).details;', }); expect(details.status).toBe("completed"); @@ -1642,6 +1680,58 @@ describe("Code Mode", () => { expect(testing.activeRuns.size).toBe(beforeRunCount); }); + it("enforces output limits before auto-draining namespace calls", async () => { + registerTestNamespace({ + id: "tickets", + pluginId: "fake-code-mode", + globalName: "Tickets", + requiredToolNames: ["fake_list_issues"], + createScope: () => ({ + list: createCodeModeNamespaceTool("fake_list_issues", ([input]) => input), + }), + }); + const catalogRef = createToolSearchCatalogRef(); + const config = { + tools: { + codeMode: { + enabled: true, + maxOutputBytes: 1024, + }, + }, + } as never; + const ctx = { + config, + runtimeConfig: config, + sessionId: "session-code-mode", + sessionKey: "agent:main:main", + runId: "run-code-mode", + catalogRef, + }; + const tools = createCodeModeTools(ctx); + const listIssues = pluginToolWithExecute("fake_list_issues", "List issues", async () => + jsonResult({ ok: true }), + ); + applyCodeModeCatalog({ + tools: [...tools, listIssues], + config, + sessionId: "session-code-mode", + sessionKey: "agent:main:main", + runId: "run-code-mode", + catalogRef, + }); + + const details = resultDetails( + await tools[0].execute("code-call-large-namespace", { + code: 'text("x".repeat(2048)); await Tickets.list({ state: "open" }); return 1;', + }), + ); + + expect(details.status).toBe("failed"); + expect(String(details.error)).toContain("output limit exceeded"); + expect(details.code).toBe("output_limit_exceeded"); + expect(listIssues.execute).not.toHaveBeenCalled(); + }); + it("preserves guest output when a run fails", async () => { const { config, catalogRef, tools } = createCodeModeHarness(); applyCodeModeCatalog({ diff --git a/src/agents/code-mode.ts b/src/agents/code-mode.ts index 6139ef16060..da1dc75f667 100644 --- a/src/agents/code-mode.ts +++ b/src/agents/code-mode.ts @@ -143,6 +143,7 @@ type CodeModeWorkerResult = const activeRuns = new Map(); const resumingRunIds = new Set(); +let activeRunReservations = 0; let typescriptRuntimePromise: Promise | null = null; let typescriptRuntimeForTest: typeof import("typescript") | null = null; @@ -261,11 +262,24 @@ function resolveCodeModeSnapshotExpiresAt(now: number, ttlSeconds: number): numb function enforceActiveRunLimit(): void { removeExpiredRuns(); - if (activeRuns.size >= MAX_ACTIVE_CODE_MODE_RUNS) { + if (activeRuns.size + activeRunReservations >= MAX_ACTIVE_CODE_MODE_RUNS) { throw new ToolInputError("too many suspended code mode runs."); } } +function reserveActiveRunSlot(): () => void { + enforceActiveRunLimit(); + activeRunReservations += 1; + let released = false; + return () => { + if (released) { + return; + } + released = true; + activeRunReservations = Math.max(0, activeRunReservations - 1); + }; +} + function toJsonSafe(value: unknown): unknown { if (value === undefined) { return null; @@ -501,6 +515,7 @@ async function runBridgeRequest(params: { const options = isRecord(values[1]) ? values[1] : undefined; value = await params.runtime.search(query, { limit: typeof options?.limit === "number" ? options.limit : undefined, + includeMcp: false, }); break; } @@ -509,7 +524,7 @@ async function runBridgeRequest(params: { if (typeof id !== "string") { throw new ToolInputError("describe id must be a string."); } - value = await params.runtime.describe(id); + value = await params.runtime.describe(id, { includeMcp: false }); break; } case "call": { @@ -517,12 +532,7 @@ async function runBridgeRequest(params: { if (typeof id !== "string") { throw new ToolInputError("call id must be a string."); } - const described = await params.runtime.describe(id); - if (described.source === "mcp") { - throw new ToolInputError( - "MCP tools are available in code mode only through the MCP namespace.", - ); - } + const described = await params.runtime.describe(id, { includeMcp: false }); value = await params.runtime.callExactId(described.id, values[1] ?? {}, { parentToolCallId: params.parentToolCallId, signal: params.signal, @@ -710,14 +720,43 @@ function snapshotState(params: { output: unknown[]; signal?: AbortSignal; onUpdate?: AgentToolUpdateCallback; +}) { + enforceSnapshotStateLimits(params); + return storeSnapshotState({ + ...params, + pending: createPendingBridgeStates(params), + }); +} + +function enforceSnapshotStateLimits(params: { + snapshotBytes: Uint8Array; + config: CodeModeConfig; + output: unknown[]; }) { enforceActiveRunLimit(); + enforceSnapshotPayloadLimits(params); +} + +function enforceSnapshotPayloadLimits(params: { + snapshotBytes: Uint8Array; + config: CodeModeConfig; + output: unknown[]; +}) { if (params.snapshotBytes.byteLength > params.config.maxSnapshotBytes) { throw new CodeModeLimitError("snapshot_limit_exceeded", "code mode snapshot limit exceeded"); } enforceOutputLimit(params.output, params.config); - const runId = `cm_${randomUUID()}`; - const pending = params.pendingRequests.map((request) => { +} + +function createPendingBridgeStates(params: { + pendingRequests: PendingBridgeRequest[]; + runtime: ToolSearchRuntime; + namespaceRuntime: CodeModeNamespaceRuntime; + parentToolCallId: string; + signal?: AbortSignal; + onUpdate?: AgentToolUpdateCallback; +}): PendingBridgeState[] { + return params.pendingRequests.map((request) => { const promise = runBridgeRequest({ runtime: params.runtime, namespaceRuntime: params.namespaceRuntime, @@ -732,6 +771,19 @@ function snapshotState(params: { }); return state; }); +} + +function storeSnapshotState(params: { + pending: PendingBridgeState[]; + snapshotBytes: Uint8Array; + parentToolCallId: string; + ctx: ToolSearchToolContext; + config: CodeModeConfig; + runtime: ToolSearchRuntime; + namespaceRuntime: CodeModeNamespaceRuntime; + output: unknown[]; +}) { + const runId = `cm_${randomUUID()}`; const now = Date.now(); const expiresAt = resolveCodeModeSnapshotExpiresAt(now, params.config.snapshotTtlSeconds); if (expiresAt === undefined) { @@ -743,7 +795,7 @@ function snapshotState(params: { ctx: params.ctx, config: params.config, snapshotBytes: params.snapshotBytes, - pending, + pending: params.pending, output: params.output, createdAt: now, expiresAt, @@ -753,8 +805,8 @@ function snapshotState(params: { return { status: "waiting" as const, runId, - reason: codeModeWaitingReason(pending), - pendingToolCalls: pendingToolCalls(pending), + reason: codeModeWaitingReason(params.pending), + pendingToolCalls: pendingToolCalls(params.pending), output: params.output, telemetry: telemetry(params.runtime), }; @@ -805,7 +857,7 @@ async function runExec(params: { throw new ToolInputError("code mode is disabled."); } const runtime = new ToolSearchRuntime(params.ctx, toToolSearchConfig(config)); - const catalog = runtime.all(); + const catalog = runtime.all({ includeMcp: false }); const namespaceRuntime = await createCodeModeNamespaceRuntime( params.ctx, runtime.namespaceEntries(), @@ -835,29 +887,17 @@ async function runExec(params: { config.timeoutMs + 1000, ), ); - if (result.status === "waiting") { - return snapshotState({ - pendingRequests: result.pendingRequests, - snapshotBytes: result.snapshotBytes, - parentToolCallId: params.toolCallId, - ctx: params.ctx, - config, - runtime, - namespaceRuntime, - output: result.output, - signal: params.signal, - onUpdate: params.onUpdate, - }); - } - enforceResultLimit({ + return await settleCodeModeResult({ + result, output: result.output, - value: result.status === "completed" ? result.value : undefined, + parentToolCallId: params.toolCallId, + ctx: params.ctx, config, + runtime, + namespaceRuntime, + signal: params.signal, + onUpdate: params.onUpdate, }); - return { - ...result, - telemetry: telemetry(runtime), - }; } catch (error) { return { status: "failed" as const, @@ -889,6 +929,107 @@ async function waitForPending(pending: PendingBridgeState[], timeoutMs: number): } } +async function settleCodeModeResult(params: { + result: CodeModeWorkerResult; + output: unknown[]; + parentToolCallId: string; + ctx: ToolSearchToolContext; + config: CodeModeConfig; + runtime: ToolSearchRuntime; + namespaceRuntime: CodeModeNamespaceRuntime; + signal?: AbortSignal; + onUpdate?: AgentToolUpdateCallback; +}) { + let result = params.result; + const output = params.output; + let namespaceRounds = 0; + const settleDeadline = Date.now() + params.config.timeoutMs; + while ( + result.status === "waiting" && + result.pendingRequests.length > 0 && + result.pendingRequests.every((request) => request.method === "namespace") && + namespaceRounds < params.config.maxPendingToolCalls + ) { + const remainingMs = settleDeadline - Date.now(); + if (remainingMs <= 0) { + break; + } + enforceSnapshotPayloadLimits({ + snapshotBytes: result.snapshotBytes, + config: params.config, + output, + }); + const releaseReservation = reserveActiveRunSlot(); + try { + const pending = createPendingBridgeStates({ + pendingRequests: result.pendingRequests, + runtime: params.runtime, + namespaceRuntime: params.namespaceRuntime, + parentToolCallId: params.parentToolCallId, + signal: params.signal, + onUpdate: params.onUpdate, + }); + const ready = await waitForPending(pending, remainingMs); + if (!ready) { + return storeSnapshotState({ + pending, + snapshotBytes: result.snapshotBytes, + parentToolCallId: params.parentToolCallId, + ctx: params.ctx, + config: params.config, + runtime: params.runtime, + namespaceRuntime: params.namespaceRuntime, + output, + }); + } + const settledRequests: SettledBridgeRequest[] = []; + for (const entry of pending) { + settledRequests.push(entry.settled ?? (await entry.promise)); + } + result = normalizeCodeModeWorkerResult( + await runCodeModeWorker( + { + kind: "resume", + snapshotBytes: result.snapshotBytes, + config: params.config, + settledRequests, + }, + Math.max(1, settleDeadline - Date.now()) + 1000, + ), + ); + } finally { + releaseReservation(); + } + output.push(...result.output); + enforceOutputLimit(output, params.config); + namespaceRounds += 1; + } + if (result.status === "waiting") { + return snapshotState({ + pendingRequests: result.pendingRequests, + snapshotBytes: result.snapshotBytes, + parentToolCallId: params.parentToolCallId, + ctx: params.ctx, + config: params.config, + runtime: params.runtime, + namespaceRuntime: params.namespaceRuntime, + output, + signal: params.signal, + onUpdate: params.onUpdate, + }); + } + enforceResultLimit({ + output, + value: result.status === "completed" ? result.value : undefined, + config: params.config, + }); + return { + ...result, + output, + telemetry: telemetry(params.runtime), + }; +} + async function runWait(params: { toolCallId: string; ctx: CodeModeToolContext; @@ -949,30 +1090,17 @@ async function runWait(params: { ); const output = [...state.output, ...result.output]; enforceOutputLimit(output, state.config); - if (result.status === "waiting") { - return snapshotState({ - pendingRequests: result.pendingRequests, - snapshotBytes: result.snapshotBytes, - parentToolCallId: params.toolCallId, - ctx: state.ctx, - config: state.config, - runtime: state.runtime, - namespaceRuntime: state.namespaceRuntime, - output, - signal: params.signal, - onUpdate: params.onUpdate, - }); - } - enforceResultLimit({ + return await settleCodeModeResult({ + result, output, - value: result.status === "completed" ? result.value : undefined, + parentToolCallId: params.toolCallId, + ctx: state.ctx, config: state.config, + runtime: state.runtime, + namespaceRuntime: state.namespaceRuntime, + signal: params.signal, + onUpdate: params.onUpdate, }); - return { - ...result, - output, - telemetry: telemetry(state.runtime), - }; } catch (error) { return { status: "failed" as const, diff --git a/src/agents/tool-search.ts b/src/agents/tool-search.ts index 4d6de477746..d104c548b71 100644 --- a/src/agents/tool-search.ts +++ b/src/agents/tool-search.ts @@ -39,6 +39,9 @@ const MAX_REUSABLE_CATALOG_SNAPSHOTS = 256; type ToolSearchMode = "code" | "tools"; type CatalogSource = "openclaw" | "mcp" | "client"; type CatalogTool = AnyAgentTool | ToolDefinition; +type CatalogVisibilityOptions = { + includeMcp?: boolean; +}; type ReusableCatalogSnapshot = { entries: ToolSearchCatalogEntry[]; @@ -1012,9 +1015,22 @@ function scoreEntry(entry: ToolSearchCatalogEntry, terms: string[]): number { return score; } -function findEntry(catalog: ToolSearchCatalogSession, id: string): ToolSearchCatalogEntry { +function visibleCatalogEntries( + catalog: ToolSearchCatalogSession, + options?: CatalogVisibilityOptions, +): ToolSearchCatalogEntry[] { + return options?.includeMcp === false + ? catalog.entries.filter((entry) => entry.source !== "mcp") + : catalog.entries; +} + +function findEntry( + catalog: ToolSearchCatalogSession, + id: string, + options?: CatalogVisibilityOptions, +): ToolSearchCatalogEntry { const needle = id.trim(); - const entry = catalog.entries.find( + const entry = visibleCatalogEntries(catalog, options).find( (candidate) => candidate.id === needle || candidate.name === needle, ); if (!entry) { @@ -1105,12 +1121,12 @@ export class ToolSearchRuntime { private readonly config: ToolSearchConfig, ) {} - search = async (query: string, options?: { limit?: number }) => { + search = async (query: string, options?: { limit?: number } & CatalogVisibilityOptions) => { const catalog = resolveCatalog(this.ctx); catalog.searchCount += 1; const limit = readLimit(options?.limit, this.config); const terms = tokenize(query); - return catalog.entries + return visibleCatalogEntries(catalog, options) .map((entry) => ({ entry, score: scoreEntry(entry, terms) })) .filter((hit) => hit.score > 0) .toSorted((a, b) => b.score - a.score || a.entry.id.localeCompare(b.entry.id)) @@ -1118,9 +1134,9 @@ export class ToolSearchRuntime { .map((hit) => compactEntry(hit.entry)); }; - all = () => { + all = (options?: CatalogVisibilityOptions) => { const catalog = resolveCatalog(this.ctx); - return catalog.entries.map((entry) => compactEntry(entry)); + return visibleCatalogEntries(catalog, options).map((entry) => compactEntry(entry)); }; namespaceEntries = () => { @@ -1132,10 +1148,10 @@ export class ToolSearchRuntime { ); }; - describe = async (id: string) => { + describe = async (id: string, options?: CatalogVisibilityOptions) => { const catalog = resolveCatalog(this.ctx); catalog.describeCount += 1; - return describeEntry(findEntry(catalog, id)); + return describeEntry(findEntry(catalog, id, options)); }; call = async ( diff --git a/ui/src/ui/control-ui-vite-config.node.test.ts b/ui/src/ui/control-ui-vite-config.node.test.ts index 6045c84cf76..59cf2f7f160 100644 --- a/ui/src/ui/control-ui-vite-config.node.test.ts +++ b/ui/src/ui/control-ui-vite-config.node.test.ts @@ -8,6 +8,12 @@ import { } from "../../vite.config.ts"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +type ResolveIdHandler = ( + this: never, + source: string, + importer: string | undefined, + options: { custom: Record; isEntry: boolean; ssr: boolean }, +) => unknown; function findStringAlias(key: string) { return resolveTsconfigPathAliasesForVite().find((alias) => alias.find === key); @@ -55,16 +61,18 @@ describe("Control UI Vite config", () => { it("uses a browser-safe redactor for shared tool display imports", async () => { const plugin = controlUiBrowserOnlySharedModuleAliases(); const resolveIdHook = plugin.resolveId; - const resolveId = typeof resolveIdHook === "function" ? resolveIdHook : resolveIdHook?.handler; - if (typeof resolveId !== "function") { + const resolveIdHandler = ( + typeof resolveIdHook === "function" ? resolveIdHook : resolveIdHook?.handler + ) as ResolveIdHandler | undefined; + if (!resolveIdHandler) { throw new Error("Expected browser-only shared module alias plugin to expose resolveId"); } - const resolved = await resolveId.call( + const resolved = await resolveIdHandler.call( {} as never, "../logging/redact.js", path.join(repoRoot, "src/agents/tool-display-common.ts"), - { attributes: {}, custom: {}, isEntry: false, ssr: false }, + { custom: {}, isEntry: false, ssr: false }, ); expect(resolved).toBe(path.join(repoRoot, "ui/src/ui/browser-redact.ts"));