Files
openclaw/src/agents/code-mode.ts
Vincent Koc 32036c473d fix(agents): resume Code Mode through gateway restarts (#116605)
* fix(agents): preserve Code Mode restart recovery

* chore(protocol): regenerate Swift agent params
2026-07-31 09:22:27 +08:00

363 lines
16 KiB
TypeScript

/**
* Host-side Code Mode controller for isolated QuickJS execution with bridged
* tool search/call/yield support.
*/
import { Type } from "typebox";
import { getAgentToolExecutionContext } from "../../packages/agent-core/src/tool-execution-context.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { HookContext } from "./agent-tools.before-tool-call.js";
import {
codeModeReplayIdForToolCall,
runBridgeRequest,
setCodeModeSwarmDepsForTest,
} from "./code-mode-bridge.js";
import {
CODE_MODE_EXEC_TOOL_NAME,
CODE_MODE_WAIT_TOOL_NAME,
isCodeModeControlTool,
markCodeModeControlTool,
} from "./code-mode-control-tools.js";
import { runExec, runWait } from "./code-mode-execution.js";
import { createHeadlessAbortScope, runCodeModeScriptHeadless } from "./code-mode-headless.js";
import { describeCodeModeNamespacesForPrompt } from "./code-mode-namespaces.js";
import {
codeModeRuntimeTesting,
isCodeModeEngagedForModel,
readCode,
readRunId,
resolveCodeModeConfig,
resolveCodeModeHeadlessConfig,
} from "./code-mode-runtime.js";
import { activeRuns, removeExpiredRuns, resumingRunIds } from "./code-mode-state.js";
import {
normalizeCodeModeTimeoutResult,
normalizeCodeModeWorkerResult,
resolveCodeModeWorkerUrl,
runCodeModeWorker,
CodeModeHeadlessAbortError,
CodeModeHeadlessTimeoutError,
} from "./code-mode-worker.js";
import type { AgentToolUpdateCallback } from "./runtime/index.js";
import { optionalStringEnum } from "./schema/typebox.js";
import type { ToolDefinition } from "./sessions/index.js";
import { resolveSwarmConfig } from "./swarm-config.js";
import { isDirectVisibleCatalogTool } from "./tool-search-catalog.js";
import {
addClientToolsToToolCatalog,
applyToolCatalogCompaction,
compactToolSearchCatalogEntry,
TOOL_CALL_RAW_TOOL_NAME,
TOOL_DESCRIBE_RAW_TOOL_NAME,
TOOL_SEARCH_CODE_MODE_TOOL_NAME,
TOOL_SEARCH_RAW_TOOL_NAME,
type ToolSearchCatalogEntry,
type ToolSearchCatalogRef,
type ToolSearchToolContext,
} from "./tool-search.js";
import { jsonResult, type AnyAgentTool } from "./tools/common.js";
export { CODE_MODE_EXEC_TOOL_NAME, CODE_MODE_WAIT_TOOL_NAME };
export {
CodeModeHeadlessAbortError,
CodeModeHeadlessTimeoutError,
isCodeModeEngagedForModel,
runCodeModeScriptHeadless,
resolveCodeModeConfig,
};
export type { CodeModeFailureCode, CodeModeHeadlessResult } from "./code-mode-runtime.js";
type CodeModeToolContext = ToolSearchToolContext;
const MAX_CODE_MODE_CATALOG_INDEX_CHARS = 8_000;
const CODE_MODE_CATALOG_INDEX_HEADING = [
"OpenClaw/plugin tool quick index (exact ids; descriptions are intentionally deferred):",
"Each line is `id input -> output`; `-> ?` means unknown.",
"OUTPUT DECLARED RULE: use declared fields for dependent calls in the first exec.",
"OUTPUT UNKNOWN RULE: return the raw tool value unchanged; inspect or map it only in a later exec.",
].join("\n");
function codeModeCatalogIndexFooter(included: number, total: number): string {
const omitted = total - included;
return omitted > 0
? `${omitted} additional OpenClaw/plugin tools omitted from this prompt index. Use ALL_TOOLS or tools.search inside exec to find them.`
: "Use these exact ids with tools.callValue; use ALL_TOOLS or tools.search inside exec when lookup is ambiguous.";
}
function renderCodeModeCatalogIndex(lines: readonly string[], total: number): string {
return [
CODE_MODE_CATALOG_INDEX_HEADING,
...lines,
"",
codeModeCatalogIndexFooter(lines.length, total),
].join("\n");
}
function formatCodeModeCatalogIndex(catalog: readonly ToolSearchCatalogEntry[]): string {
const lines = catalog
.filter((entry) => entry.source === "openclaw")
.map((entry) => compactToolSearchCatalogEntry(entry))
// Declared-output entries sort first so byte truncation drops `-> ?`
// lines, which stay fully discoverable through ALL_TOOLS, before it drops
// contracts the model can one-pass on. Deterministic within each tier.
.toSorted((a, b) => (a.output ? 0 : 1) - (b.output ? 0 : 1) || a.id.localeCompare(b.id))
.map(
(entry) =>
`- ${JSON.stringify(entry.id)} ${entry.input ?? "unknown"} -> ${entry.output ?? "?"}`,
);
if (lines.length === 0) {
return "";
}
const fullIndex = renderCodeModeCatalogIndex(lines, lines.length);
if (fullIndex.length <= MAX_CODE_MODE_CATALOG_INDEX_CHARS) {
return fullIndex;
}
// Greedily pack lines in the deterministic sorted order, skipping any single
// line too large to fit rather than dropping the whole tail after it. A prefix
// cut let one oversized entry — a pathological plugin id or input hint — blank
// the entire index; skipping it keeps every other declared contract visible
// and fits more of them when the declared tier alone overflows. Skipped
// entries stay discoverable through ALL_TOOLS, and the stable input order
// keeps prompt bytes deterministic for provider caches.
const included: string[] = [];
let includedLineLength = 0;
for (const line of lines) {
const candidateLineLength = includedLineLength + 1 + line.length;
const candidateLength =
CODE_MODE_CATALOG_INDEX_HEADING.length +
candidateLineLength +
2 +
codeModeCatalogIndexFooter(included.length + 1, lines.length).length;
if (candidateLength <= MAX_CODE_MODE_CATALOG_INDEX_CHARS) {
included.push(line);
includedLineLength = candidateLineLength;
}
}
return renderCodeModeCatalogIndex(included, lines.length);
}
function createCodeModeExecDescription(
ctx: CodeModeToolContext,
catalog?: readonly ToolSearchCatalogEntry[],
): string {
const namespacePrompt = describeCodeModeNamespacesForPrompt(catalog);
// A known run catalog with neither MCP nor swarm has no virtual API files.
const catalogKnown = catalog !== undefined;
const hasMcp = catalog?.some((entry) => entry.source === "mcp") ?? false;
const swarmEnabled = resolveSwarmConfig(ctx.runtimeConfig ?? ctx.config, ctx.agentId).enabled;
const apiGuidance =
!catalogKnown || hasMcp || swarmEnabled
? " Read TypeScript-style declaration files with `API.list(prefix?)` and `API.read(path)`."
: "";
const mcpGuidance =
!catalogKnown || hasMcp ? " MCP tools are available only through the `MCP` namespace." : "";
const swarmGuidance = swarmEnabled
? " Swarm globals `agents.run`, `phase`, and `log` are available; read `agents.d.ts` for types and orchestration idioms."
: "";
const nodesGuidance =
"\n- nodes: paired Gateway nodes; nodes.list(), (await nodes.get(id)).invoke(command, params)\n";
const skillsGuidance = ctx.codeModeSkills?.length
? " Skills are available through the async `skills` global: use `await skills.list()` and `await skills.read(name)`."
: "";
const catalogIndex = catalog ? formatCodeModeCatalogIndex(catalog) : "";
return (
"Run JavaScript or TypeScript in OpenClaw code mode. Use `return` to pass the final value back; otherwise the result is `null`. Quick-index arrows show trusted declared output hints; `-> ?` means never guess result field names. For declared fields, process them in the first exec; do not spend another exec inspecting them. Perform dependent reads, checks, and follow-up calls in order; parallelize independent work only. For an unknown output, including a final dependent call after declared-output calls, return the raw tool value unchanged; do not wrap it in the requested answer shape or guess fields; filter or map it only in a later exec. Nested calls enforce normal tool policy and approvals. `ALL_TOOLS` is the complete compact catalog. Select exact ids directly or with `tools.search(query: string, options?)`; use `tools.describe(id: string)` only when needed. Never invent or transform a tool id. `tools.callValue(id: string, args?)` returns its JSON value directly; `tools.call(id: string, args?)` preserves `{ tool, result }`. Example: `const hit = ALL_TOOLS.find((entry) => entry.description.includes('weather')) ?? (await tools.search('weather'))[0]; return await tools.callValue(hit.id, {});`. Node.js modules and `require`/`import` are NOT available; use enabled catalog tools allowed by policy for shell, file, network, or external actions." +
apiGuidance +
mcpGuidance +
swarmGuidance +
nodesGuidance +
skillsGuidance +
' The `language` field accepts only "javascript" or "typescript"; do not pass "bash", "shell", or other values.' +
" The `code` field contains JavaScript or TypeScript, never a shell command. " +
"For shell or file operations, call the exact catalog tool from guest JavaScript; do not retry failed shell source." +
(namespacePrompt ? `\n\n${namespacePrompt}` : "") +
(catalogIndex ? `\n\n${catalogIndex}` : "")
);
}
export function createCodeModeTools(ctx: CodeModeToolContext): AnyAgentTool[] {
const execTool = markCodeModeControlTool({
name: CODE_MODE_EXEC_TOOL_NAME,
label: "exec",
description: createCodeModeExecDescription(ctx),
parameters: Type.Object({
// `command` stays runtime-only for hook compatibility. Requiring the sole
// model-facing field prevents schema-valid empty calls from constrained models.
code: Type.String({
description:
'Required JS/TS; no Python, shell, `require`, `import`. Use explicit `return value`; a trailing expression is discarded and yields `null`. Use `callValue`, not `call`, for data; `call` wraps it under `.result`. Core text reads: `{kind:"text",content:string}`; use `.content`. Unknown format: return it first, then parse it in a later exec; never guess separators. Example: `const file=await tools.callValue("openclaw:core:read", { path: "notes.txt" }); if(file.kind!=="text") return file; return file.content;`. Use exact ids from `ALL_TOOLS` or `tools.search(query)`; never invent ids or parallelize dependent calls.',
}),
language: optionalStringEnum(["javascript", "typescript"] as const, {
description:
'Source language. Must be "javascript" or "typescript". Defaults to javascript.',
}),
restartSafe: Type.Optional(
Type.Boolean({
description:
"Set true only when every catalog call is explicitly replay-safe and OpenClaw may reconstruct the work after a gateway restart. Leave unset for ordinary calls; true rejects unmarked, side-effecting, or namespace tool calls.",
}),
),
}),
execute: async (
toolCallId: string,
args: unknown,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback,
) => {
const input = readCode(args);
const executionContext = getAgentToolExecutionContext();
return jsonResult(
normalizeCodeModeTimeoutResult(
await runExec({
toolCallId,
ctx,
code: input.code,
assistantTurnId:
executionContext?.assistantMessage.responseId?.trim() ||
executionContext?.assistantMessage.turnId?.trim(),
language: input.language,
restartSafe: ctx.forceRestartSafeTools === true || input.restartSafe,
signal,
onUpdate,
}),
),
);
},
} as AnyAgentTool);
const waitTool = markCodeModeControlTool({
name: CODE_MODE_WAIT_TOOL_NAME,
label: "wait",
hideFromChannelProgress: true,
description: "Resume a suspended OpenClaw code mode run returned by exec.",
parameters: Type.Object({
runId: Type.String({ description: "Code mode run id returned by exec." }),
}),
execute: async (
toolCallId: string,
args: unknown,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback,
) =>
jsonResult(
normalizeCodeModeTimeoutResult(
await runWait({
toolCallId,
ctx,
runId: readRunId(args),
signal,
onUpdate,
}),
),
),
} as AnyAgentTool);
return [execTool, waitTool];
}
/** Compact normal tools behind Code Mode exec/wait controls. */
export function applyCodeModeCatalog(params: {
tools: AnyAgentTool[];
config?: OpenClawConfig;
sessionId?: string;
sessionKey?: string;
agentId?: string;
runId?: string;
catalogRef?: ToolSearchCatalogRef;
toolHookContext?: HookContext;
directToolNames?: Iterable<string>;
codeModeSkills?: CodeModeToolContext["codeModeSkills"];
forceEnabled?: boolean;
}) {
const config = resolveCodeModeConfig(params.config, params.agentId);
// Engagement (including "auto" per-model resolution) is decided by the run
// gates before this is called; only a hard `false` may disable compaction.
if (config.enabled === false && params.forceEnabled !== true) {
return applyToolCatalogCompaction({
...params,
enabled: false,
isVisibleControlTool: isCodeModeControlTool,
});
}
const tools = params.tools.filter(
(tool) =>
isCodeModeControlTool(tool) ||
(tool.name !== TOOL_SEARCH_CODE_MODE_TOOL_NAME &&
tool.name !== TOOL_SEARCH_RAW_TOOL_NAME &&
tool.name !== TOOL_DESCRIBE_RAW_TOOL_NAME &&
tool.name !== TOOL_CALL_RAW_TOOL_NAME),
);
const directToolNames = new Set(params.directToolNames);
const compacted = applyToolCatalogCompaction({
...params,
tools,
enabled: true,
isVisibleControlTool: isCodeModeControlTool,
// Code mode never exposes core shell/file tools just because structured
// search does; only explicitly required, trusted direct tools may remain.
isVisibleCatalogTool: (tool) =>
directToolNames.has(tool.name) && isDirectVisibleCatalogTool(tool, directToolNames),
shouldCatalogTool: (tool) => !isCodeModeControlTool(tool),
});
// Only the catalog ref reflects the freshly compacted run catalog. Without it
// the real catalog is registered under session keys and resolved later, so
// keep the catalog "unknown" (undefined) rather than an empty array that would
// wrongly strip MCP/namespace guidance from the exec description.
const visibleCatalog = params.catalogRef?.current?.entries;
for (const tool of compacted.tools) {
if (tool.name === CODE_MODE_EXEC_TOOL_NAME) {
tool.description = createCodeModeExecDescription(
{
config: params.config,
runtimeConfig: params.config,
agentId: params.agentId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
runId: params.runId,
catalogRef: params.catalogRef,
codeModeSkills: params.codeModeSkills,
},
visibleCatalog,
);
}
}
return compacted;
}
/** Move client-side tool definitions into the active Code Mode catalog. */
export function addClientToolsToCodeModeCatalog(params: {
tools: ToolDefinition[];
config?: OpenClawConfig;
sessionId?: string;
sessionKey?: string;
agentId?: string;
runId?: string;
catalogRef?: ToolSearchCatalogRef;
}) {
return addClientToolsToToolCatalog({
...params,
// Callers gate on run engagement; "auto" counts as enabled here.
enabled: resolveCodeModeConfig(params.config, params.agentId).enabled !== false,
});
}
/** Test-only hooks and state accessors for Code Mode worker orchestration. */
const testing = {
activeRuns,
resumingRunIds,
codeModeReplayIdForToolCall,
removeExpiredRuns,
runBridgeRequest,
createHeadlessAbortScope,
normalizeCodeModeWorkerResult,
runCodeModeWorker,
resolveCodeModeHeadlessConfig,
resolveCodeModeWorkerUrl,
getTypescriptRuntimePromise: codeModeRuntimeTesting.getTypescriptRuntimePromise,
setTypescriptRuntimeForTest: codeModeRuntimeTesting.setTypescriptRuntimeForTest,
setSwarmDepsForTest: setCodeModeSwarmDepsForTest,
};
if (process.env.VITEST || process.env.NODE_ENV === "test") {
(globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw.codeModeTestApi")] = testing;
}