feat: add typed MCP code-mode API (#88678)

* feat: add typed MCP code-mode API

* fix: stabilize code-mode namespace drain

* fix: preserve code-mode run cap

* fix: reserve code-mode snapshot capacity
This commit is contained in:
Peter Steinberger
2026-05-31 17:51:22 +01:00
committed by GitHub
parent d1b514af2e
commit 4150c6ff82
8 changed files with 1147 additions and 117 deletions

View File

@@ -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.<server>.$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<McpApiHeader>;
/**
* 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<McpToolResult>;
}
```
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.<server>.<tool>(...)` 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.<server>.<tool>({ ...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.<server>.<tool>(...)` 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.<server>.$api()` and assert the headers
describe visible MCP tools.
10. In `exec`, call MCP tools through `MCP.<server>.<tool>({ ...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`.

View File

@@ -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)

View File

@@ -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<number> {
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<unknown> {
const timeoutMs = 180_000;
const controller = new AbortController();
let timeout: ReturnType<typeof setNodeTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, 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<Record<string, number>> {
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<ReturnType<typeof startQaMockOpenAiServer>> | undefined;
let server: Awaited<ReturnType<typeof startGatewayServer>> | 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();
}

View File

@@ -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<PropertyKey, unknown>) : undefined;
return (
@@ -342,6 +360,12 @@ function readSchemaProperties(schema: unknown): Record<string, unknown> {
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<string, unknown> = 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<string, unknown> =
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<string, unknown>";
}
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<CodeModeNamespaceCatalogEntry["mcp"]>["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<string, never>"];
}
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<McpToolResult>;");
return lines;
}
function renderMcpServerHeader(server: McpApiServerDoc, tools: readonly McpApiToolDoc[]): string {
const lines = [
"type McpApiHeader = { header: string; tools?: unknown[]; schemas?: Record<string, unknown> };",
"",
"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<McpApiHeader>;",
];
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<string, McpApiToolDoc[]>();
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<McpApiHeader>;",
...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.<server>.$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<string>(["resources", "prompts"]);
const created = new Set<string>(["$api", "resources", "prompts"]);
usedToolIdentifiers.set(serverIdentifier, created);
return created;
}
@@ -446,6 +759,7 @@ function createMcpNamespaceScope(
}
const usedToolIdentifiers = new Map<string, Set<string>>();
const root = Object.create(null) as CodeModeNamespaceScope;
const serverDocs = new Map<string, McpApiServerDoc>();
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.<server>.<tool>(args) for MCP tools; visible servers: ${servers.join(", ")}.`,
`Use MCP.$api() or MCP.<server>.$api() to inspect TypeScript-style API headers; visible servers: ${servers.join(", ")}.`,
"Call MCP tools as MCP.<server>.<tool>({ ...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,

View File

@@ -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({

View File

@@ -143,6 +143,7 @@ type CodeModeWorkerResult =
const activeRuns = new Map<string, CodeModeRunState>();
const resumingRunIds = new Set<string>();
let activeRunReservations = 0;
let typescriptRuntimePromise: Promise<typeof import("typescript")> | 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,

View File

@@ -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 (

View File

@@ -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<string, never>; 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"));