fix: code mode dead-ends on oversized tool results instead of returning bounded output (#122924)

* fix(agents): bound code mode bridge results

Return oversized bridge, guest output, and final values as bounded successful projections with actionable narrowing guidance.

* refactor(agents): absorb code mode output bounds
This commit is contained in:
Peter Steinberger
2026-08-12 19:24:18 -07:00
committed by GitHub
parent 6076efc968
commit e6ca5266af
18 changed files with 409 additions and 305 deletions
+16 -6
View File
@@ -796,10 +796,15 @@ the bridge as JSON-compatible values with explicit size caps.
type CodeModeOutput = { type: "text"; text: string } | { type: "json"; value: unknown }; type CodeModeOutput = { type: "text"; text: string } | { type: "json"; value: unknown };
``` ```
Rules: output order matches guest calls; output is capped by Rules: output order matches guest calls. Nested tool results, cumulative guest
`maxOutputBytes`; non-serializable values are converted to plain strings or output, and the final value share the `maxOutputBytes` serialized UTF-8 budget.
errors; binary values are not supported. Images and files travel through When a successful result exceeds the budget, OpenClaw returns a bounded value
ordinary OpenClaw tools, not through the code-mode bridge. with `truncated: true`, a UTF-8-safe `prefix`, `omittedBytes`, and guidance to
rerun with narrower arguments. Treat that marker as a successful partial result:
reduce the search scope, paginate, select fewer files, or return a smaller
projection. Non-serializable values are converted to plain strings or errors;
binary values are not supported. Images and files travel through ordinary
OpenClaw tools, not through the code-mode bridge.
## Tool catalog ## Tool catalog
@@ -905,8 +910,10 @@ session.`.
`completed` or `failed`, or is dropped on Gateway shutdown (nothing `completed` or `failed`, or is dropped on Gateway shutdown (nothing
survives a restart: this is transient runtime state). survives a restart: this is transient runtime state).
- For read-only work, `exec` can set `restartSafe: true`. OpenClaw then rejects - For read-only work, `exec` can set `restartSafe: true`. OpenClaw then rejects
side-effecting catalog and namespace tool calls before execution and catalog and namespace tool surfaces that are not proven replay-safe before
marks suspended results as replay-safe. If a restart interrupts `wait`, execution and marks suspended results as replay-safe. A generic exec surface
is not replay-safe merely because one command appears read-only; recovery
runs should use the audited read, grep, or find tools. If a restart interrupts `wait`,
[restart recovery](/gateway/restart-recovery) reconstructs the turn from the [restart recovery](/gateway/restart-recovery) reconstructs the turn from the
transcript instead of restoring the process-local snapshot. The recovery transcript instead of restoring the process-local snapshot. The recovery
turn itself remains limited to audited read-only core tools and explicitly turn itself remains limited to audited read-only core tools and explicitly
@@ -981,6 +988,9 @@ type CodeModeErrorCode =
rejected module access, TypeScript transform failures, unknown/expired/ rejected module access, TypeScript transform failures, unknown/expired/
wrong-scope `runId` values, and too many suspended runs. `runtime_unavailable` wrong-scope `runId` values, and too many suspended runs. `runtime_unavailable`
covers a QuickJS worker that fails to start or exits non-zero. covers a QuickJS worker that fails to start or exits non-zero.
`output_limit_exceeded` is reserved for a result that cannot be serialized into
the bounded projection; ordinary oversized successful results are truncated and
remain successful.
Errors returned to the guest are plain data; host `Error` instances, stack Errors returned to the guest are plain data; host `Error` instances, stack
objects, prototypes, and host functions do not cross into QuickJS. objects, prototypes, and host functions do not cross into QuickJS.
+7 -2
View File
@@ -6,7 +6,7 @@ import { NODE_FS_LIST_DIR_COMMAND } from "../infra/node-commands.js";
import { emitSessionLifecycleEvent } from "../sessions/session-lifecycle-events.js"; import { emitSessionLifecycleEvent } from "../sessions/session-lifecycle-events.js";
import { parseNodeList } from "../shared/node-list-parse.js"; import { parseNodeList } from "../shared/node-list-parse.js";
import type { NodeListNode } from "../shared/node-list-types.js"; import type { NodeListNode } from "../shared/node-list-types.js";
import { toCodeModeJsonSafe } from "./code-mode-json.js"; import { boundCodeModeValue } from "./code-mode-json.js";
import type { CodeModeNamespaceRuntime } from "./code-mode-namespaces.js"; import type { CodeModeNamespaceRuntime } from "./code-mode-namespaces.js";
import type { PendingBridgeRequest, SettledBridgeRequest } from "./code-mode-runtime.js"; import type { PendingBridgeRequest, SettledBridgeRequest } from "./code-mode-runtime.js";
import { readCodeModeSkill } from "./code-mode-skills.js"; import { readCodeModeSkill } from "./code-mode-skills.js";
@@ -393,6 +393,7 @@ export async function runBridgeRequest(params: {
namespaceRuntime: CodeModeNamespaceRuntime; namespaceRuntime: CodeModeNamespaceRuntime;
parentToolCallId: string; parentToolCallId: string;
codeModeRunId: string; codeModeRunId: string;
maxOutputBytes: number;
ctx: ToolSearchToolContext; ctx: ToolSearchToolContext;
request: PendingBridgeRequest; request: PendingBridgeRequest;
signal?: AbortSignal; signal?: AbortSignal;
@@ -542,7 +543,11 @@ export async function runBridgeRequest(params: {
break; break;
} }
} }
return { id: params.request.id, ok: true, value: toCodeModeJsonSafe(value) }; return {
id: params.request.id,
ok: true,
value: boundCodeModeValue(value, params.maxOutputBytes),
};
} catch (error) { } catch (error) {
return { id: params.request.id, ok: false, error: formatErrorMessage(error) }; return { id: params.request.id, ok: false, error: formatErrorMessage(error) };
} }
+17 -13
View File
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { codeModeReplayIdForToolCall } from "./code-mode-bridge.js"; import { codeModeReplayIdForToolCall } from "./code-mode-bridge.js";
import { awaitCodeModeDeadline } from "./code-mode-deadline.js"; import { awaitCodeModeDeadline } from "./code-mode-deadline.js";
import { boundCodeModeResult } from "./code-mode-json.js";
import { import {
createCodeModeNamespaceRuntime, createCodeModeNamespaceRuntime,
type CodeModeNamespaceRuntime, type CodeModeNamespaceRuntime,
@@ -10,8 +11,7 @@ import {
codeModeFailureCode, codeModeFailureCode,
codeModeFailureMessage, codeModeFailureMessage,
createCodeModeApiFilesForRun, createCodeModeApiFilesForRun,
enforceOutputLimit, boundOutputToLimit,
enforceResultLimit,
enforceSnapshotPayloadLimits, enforceSnapshotPayloadLimits,
prepareSource, prepareSource,
resolveCodeModeConfig, resolveCodeModeConfig,
@@ -240,7 +240,7 @@ async function settleCodeModeResult(params: {
let pending = params.pending ?? []; let pending = params.pending ?? [];
const activeRunId = params.activeRunId ?? `cm_${randomUUID()}`; const activeRunId = params.activeRunId ?? `cm_${randomUUID()}`;
const output = params.output; const output = params.output;
const deliveredOutputCount = params.deliveredOutputCount ?? 0; let deliveredOutputCount = params.deliveredOutputCount ?? 0;
// One exec/wait call shares a single wall-clock deadline across its initial // One exec/wait call shares a single wall-clock deadline across its initial
// worker run and this inline settle phase, so auto-draining bridge calls // worker run and this inline settle phase, so auto-draining bridge calls
// cannot stack a second full `timeoutMs` budget on top of the run that // cannot stack a second full `timeoutMs` budget on top of the run that
@@ -300,7 +300,6 @@ async function settleCodeModeResult(params: {
enforceSnapshotPayloadLimits({ enforceSnapshotPayloadLimits({
snapshotBytes: result.snapshotBytes, snapshotBytes: result.snapshotBytes,
config: params.config, config: params.config,
output,
}); });
if (!params.reservedActiveRunSlot) { if (!params.reservedActiveRunSlot) {
releaseReservation = reserveActiveRunSlot(); releaseReservation = reserveActiveRunSlot();
@@ -317,6 +316,7 @@ async function settleCodeModeResult(params: {
pending.push( pending.push(
...createPendingBridgeStates({ ...createPendingBridgeStates({
pendingRequests: newPendingRequests, pendingRequests: newPendingRequests,
config: params.config,
runtime: params.runtime, runtime: params.runtime,
namespaceRuntime: params.namespaceRuntime, namespaceRuntime: params.namespaceRuntime,
parentToolCallId: params.parentToolCallId, parentToolCallId: params.parentToolCallId,
@@ -388,7 +388,9 @@ async function settleCodeModeResult(params: {
), ),
); );
output.push(...result.output); output.push(...result.output);
enforceOutputLimit(output, params.config); if (boundOutputToLimit(output, params.config)) {
deliveredOutputCount = 0;
}
} catch (error) { } catch (error) {
cancelPendingBridgeStates(pending); cancelPendingBridgeStates(pending);
throw error; throw error;
@@ -409,7 +411,8 @@ async function settleCodeModeResult(params: {
cancelPendingBridgeStates(pending); cancelPendingBridgeStates(pending);
return { return {
status: "failed" as const, status: "failed" as const,
error: "restart-safe code mode cannot call side-effecting tools.", error:
"restart-safe code mode cannot call tool surfaces that are not proven replay-safe; recovery runs must use audited read, grep, or find tools.",
code: "invalid_input" as const, code: "invalid_input" as const,
failurePhase: params.bridgeDispatch.started ? ("bridge" as const) : ("input" as const), failurePhase: params.bridgeDispatch.started ? ("bridge" as const) : ("input" as const),
bridgeDispatchStarted: params.bridgeDispatch.started, bridgeDispatchStarted: params.bridgeDispatch.started,
@@ -426,7 +429,6 @@ async function settleCodeModeResult(params: {
enforceSnapshotPayloadLimits({ enforceSnapshotPayloadLimits({
snapshotBytes: result.snapshotBytes, snapshotBytes: result.snapshotBytes,
config: params.config, config: params.config,
output,
}); });
// Reserve before launching fresh work; transferred snapshots must // Reserve before launching fresh work; transferred snapshots must
// obey the same process-wide active-run cap as initial suspensions. // obey the same process-wide active-run cap as initial suspensions.
@@ -443,6 +445,7 @@ async function settleCodeModeResult(params: {
pending.push( pending.push(
...createPendingBridgeStates({ ...createPendingBridgeStates({
pendingRequests: newPendingRequests, pendingRequests: newPendingRequests,
config: params.config,
runtime: params.runtime, runtime: params.runtime,
namespaceRuntime: params.namespaceRuntime, namespaceRuntime: params.namespaceRuntime,
parentToolCallId: params.parentToolCallId, parentToolCallId: params.parentToolCallId,
@@ -499,20 +502,21 @@ async function settleCodeModeResult(params: {
// Defensive cleanup covers aborts or terminal failures; successful runs have // Defensive cleanup covers aborts or terminal failures; successful runs have
// already drained every dispatched call before releasing their snapshot. // already drained every dispatched call before releasing their snapshot.
cancelPendingBridgeStates(pending); cancelPendingBridgeStates(pending);
enforceResultLimit({ const bounded = boundCodeModeResult({
output, output,
value: result.status === "completed" ? result.value : undefined, ...(result.status === "completed" ? { value: result.value } : {}),
config: params.config, maxOutputBytes: params.config.maxOutputBytes,
}); });
return { return {
...result, ...result,
...(result.status === "completed" ? { value: bounded.value } : {}),
...(result.status === "failed" ...(result.status === "failed"
? { ? {
failurePhase: params.bridgeDispatch.started ? ("bridge" as const) : result.failurePhase, failurePhase: params.bridgeDispatch.started ? ("bridge" as const) : result.failurePhase,
bridgeDispatchStarted: params.bridgeDispatch.started, bridgeDispatchStarted: params.bridgeDispatch.started,
} }
: {}), : {}),
output: output.slice(deliveredOutputCount), output: bounded.output.slice(bounded.truncated ? 0 : deliveredOutputCount),
replaySafe: params.replaySafe, replaySafe: params.replaySafe,
telemetry: telemetry(params.runtime), telemetry: telemetry(params.runtime),
}; };
@@ -616,7 +620,7 @@ export async function runWait(params: {
), ),
); );
const output = [...state.output, ...result.output]; const output = [...state.output, ...result.output];
enforceOutputLimit(output, state.config); const outputTruncated = boundOutputToLimit(output, state.config);
return await settleCodeModeResult({ return await settleCodeModeResult({
result, result,
output, output,
@@ -629,7 +633,7 @@ export async function runWait(params: {
runtime: state.runtime, runtime: state.runtime,
namespaceRuntime: state.namespaceRuntime, namespaceRuntime: state.namespaceRuntime,
bridgeDispatch: { started: true }, bridgeDispatch: { started: true },
deliveredOutputCount: state.deliveredOutputCount, deliveredOutputCount: outputTruncated ? 0 : state.deliveredOutputCount,
pending, pending,
activeRunId: state.runId, activeRunId: state.runId,
reservedActiveRunSlot: true, reservedActiveRunSlot: true,
+9 -19
View File
@@ -650,7 +650,7 @@ describe("headless Code Mode", () => {
it("bounds output and returned values across separate worker legs", async () => { it("bounds output and returned values across separate worker legs", async () => {
const tool = fakeTool("output_boundary", async () => jsonResult({ ok: true })); const tool = fakeTool("output_boundary", async () => jsonResult({ ok: true }));
const result = expectFailed( const result = expectCompleted(
await runCodeModeScriptHeadless({ await runCodeModeScriptHeadless({
ctx: createHeadlessHarness([tool]), ctx: createHeadlessHarness([tool]),
code: ` code: `
@@ -662,7 +662,11 @@ describe("headless Code Mode", () => {
}), }),
); );
expect(result.code).toBe("output_limit_exceeded"); expect(JSON.stringify(result)).toContain("rerun with narrower args");
expect(
Buffer.byteLength(JSON.stringify(result.output), "utf8") +
Buffer.byteLength(JSON.stringify(result.value), "utf8"),
).toBeLessThanOrEqual(1_024);
expect(tool.execute).toHaveBeenCalledOnce(); expect(tool.execute).toHaveBeenCalledOnce();
}); });
@@ -891,29 +895,15 @@ describe("headless Code Mode", () => {
} }
}); });
it.each([ it("classifies syntax errors", async () => {
{
name: "syntax errors",
code: "return (;",
expectedCode: "internal_error",
overrides: undefined,
},
{
name: "output overages",
code: `text("x".repeat(2048)); return true;`,
expectedCode: "output_limit_exceeded",
overrides: { maxOutputBytes: 1024 },
},
])("classifies $name", async ({ code, expectedCode, overrides }) => {
const result = expectFailed( const result = expectFailed(
await runCodeModeScriptHeadless({ await runCodeModeScriptHeadless({
ctx: createHeadlessHarness(), ctx: createHeadlessHarness(),
code, code: "return (;",
overrides,
}), }),
); );
expect(result.code).toBe(expectedCode); expect(result.code).toBe("internal_error");
}); });
it("clamps headless limit overrides to worker-safe bounds", () => { it("clamps headless limit overrides to worker-safe bounds", () => {
+16 -7
View File
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { clampNumber } from "../utils.js"; import { clampNumber } from "../utils.js";
import { awaitCodeModeDeadline } from "./code-mode-deadline.js"; import { awaitCodeModeDeadline } from "./code-mode-deadline.js";
import { toCodeModeJsonSafe } from "./code-mode-json.js"; import { boundCodeModeResult, toCodeModeJsonSafe } from "./code-mode-json.js";
import { import {
createCodeModeNamespaceRuntime, createCodeModeNamespaceRuntime,
type CodeModeNamespaceDescriptor, type CodeModeNamespaceDescriptor,
@@ -16,8 +16,7 @@ import {
codeModeFailureCode, codeModeFailureCode,
codeModeFailureMessage, codeModeFailureMessage,
createCodeModeApiFilesForRun, createCodeModeApiFilesForRun,
enforceOutputLimit, boundOutputToLimit,
enforceResultLimit,
enforceSnapshotPayloadLimits, enforceSnapshotPayloadLimits,
prepareSource, prepareSource,
readPositiveInteger, readPositiveInteger,
@@ -253,10 +252,19 @@ export async function runCodeModeScriptHeadless(params: {
while (true) { while (true) {
output.push(...result.output); output.push(...result.output);
enforceOutputLimit(output, config); boundOutputToLimit(output, config);
if (result.status === "completed") { if (result.status === "completed") {
enforceResultLimit({ output, value: result.value, config }); const bounded = boundCodeModeResult({
return { status: "completed", value: result.value, output, toolCallCount }; output,
value: result.value,
maxOutputBytes: config.maxOutputBytes,
});
return {
status: "completed",
value: bounded.value,
output: bounded.output,
toolCallCount,
};
} }
if (result.status === "failed") { if (result.status === "failed") {
return headlessFailure({ return headlessFailure({
@@ -267,7 +275,7 @@ export async function runCodeModeScriptHeadless(params: {
}); });
} }
enforceSnapshotPayloadLimits({ snapshotBytes: result.snapshotBytes, config, output }); enforceSnapshotPayloadLimits({ snapshotBytes: result.snapshotBytes, config });
const pendingIds = new Set(pending.map((entry) => entry.id)); const pendingIds = new Set(pending.map((entry) => entry.id));
const newRequests = result.pendingRequests.filter((request) => !pendingIds.has(request.id)); const newRequests = result.pendingRequests.filter((request) => !pendingIds.has(request.id));
// Node discovery invokes the generic nodes tool for live status too; // Node discovery invokes the generic nodes tool for live status too;
@@ -292,6 +300,7 @@ export async function runCodeModeScriptHeadless(params: {
pending.push( pending.push(
...createPendingBridgeStates({ ...createPendingBridgeStates({
pendingRequests: newRequests, pendingRequests: newRequests,
config,
runtime, runtime,
namespaceRuntime, namespaceRuntime,
parentToolCallId, parentToolCallId,
+75
View File
@@ -1,3 +1,6 @@
import { jsonUtf8Bytes } from "../infra/json-utf8-bytes.js";
import { truncateUtf8Prefix } from "../utils/utf8-truncate.js";
export function toCodeModeJsonSafe(value: unknown): unknown { export function toCodeModeJsonSafe(value: unknown): unknown {
if (value === undefined) { if (value === undefined) {
return null; return null;
@@ -26,3 +29,75 @@ export function toCodeModeJsonSafe(value: unknown): unknown {
} }
} }
} }
const TRUNCATION_GUIDANCE = "Output truncated; rerun with narrower args.";
function truncationMarker(serialized: string, maxBytes: number): unknown {
const sourceBytes = Buffer.byteLength(serialized, "utf8");
let prefix = truncateUtf8Prefix(serialized, maxBytes);
while (true) {
const prefixBytes = Buffer.byteLength(prefix, "utf8");
const candidate = {
truncated: true,
omittedBytes: sourceBytes - prefixBytes,
guidance: TRUNCATION_GUIDANCE,
prefix,
};
const overflow = jsonUtf8Bytes(candidate) - maxBytes;
if (overflow <= 0 || prefixBytes === 0) {
return candidate;
}
prefix = truncateUtf8Prefix(prefix, Math.max(0, prefixBytes - overflow));
}
}
/** Bound one JSON-compatible value, preserving a UTF-8-safe serialized prefix. */
export function boundCodeModeValue(value: unknown, maxBytes: number): unknown {
const safe = toCodeModeJsonSafe(value);
const serialized = JSON.stringify(safe) ?? "null";
return Buffer.byteLength(serialized, "utf8") <= maxBytes
? safe
: truncationMarker(serialized, maxBytes);
}
function boundOutputArray(output: unknown[], maxBytes: number): unknown[] {
if (jsonUtf8Bytes(output) <= maxBytes) {
return output;
}
return [truncationMarker(JSON.stringify(output), maxBytes - 2)];
}
/** Bound cumulative guest output and the final value under one serialized byte budget. */
export function boundCodeModeResult(params: {
output: unknown[];
value?: unknown;
maxOutputBytes: number;
}): { output: unknown[]; value?: unknown; truncated: boolean } {
const hasValue = Object.hasOwn(params, "value");
const safeOutput = params.output.map(toCodeModeJsonSafe);
const safeValue = hasValue ? toCodeModeJsonSafe(params.value) : undefined;
const outputBytes = safeOutput.length > 0 ? jsonUtf8Bytes(safeOutput) : 0;
const valueBytes = hasValue ? jsonUtf8Bytes(safeValue) : 0;
if (outputBytes + valueBytes <= params.maxOutputBytes) {
return { output: safeOutput, ...(hasValue ? { value: safeValue } : {}), truncated: false };
}
if (safeOutput.length === 0) {
return {
output: [],
...(hasValue ? { value: boundCodeModeValue(safeValue, params.maxOutputBytes) } : {}),
truncated: true,
};
}
// Preserve both channels when both overflow: reserve half for the final
// value, then let short values donate their unused share to guest output.
const reservedValueBytes = hasValue
? Math.min(valueBytes, Math.floor(params.maxOutputBytes / 2))
: 0;
const output = boundOutputArray(safeOutput, params.maxOutputBytes - reservedValueBytes);
if (!hasValue) {
return { output, truncated: true };
}
const remainingBytes = params.maxOutputBytes - jsonUtf8Bytes(output);
return { output, value: boundCodeModeValue(safeValue, remainingBytes), truncated: true };
}
+36 -25
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { boundCodeModeResult } from "./code-mode-json.js";
import { import {
enforceOutputLimit, boundOutputToLimit,
enforceResultLimit,
isCodeModeEngagedForModel, isCodeModeEngagedForModel,
prepareSource, prepareSource,
resolveCodeModeConfig, resolveCodeModeConfig,
@@ -10,43 +10,54 @@ import { parseCodeModeScriptSyntax } from "./code-mode-script-syntax.js";
const config = resolveCodeModeConfig({ tools: { codeMode: true } } as never); const config = resolveCodeModeConfig({ tools: { codeMode: true } } as never);
describe("Code Mode output accounting", () => { describe("Code Mode output bounding", () => {
it("accepts Unicode output at its exact serialized byte limit", () => { it("preserves Unicode output at its exact serialized byte limit", () => {
const output = [{ type: "text", text: "😀 café" }]; const output = [{ type: "text", text: "😀 café".repeat(200) }];
const maxOutputBytes = Buffer.byteLength(JSON.stringify(output), "utf8"); const maxOutputBytes = Buffer.byteLength(JSON.stringify(output), "utf8");
expect(() => enforceOutputLimit(output, { ...config, maxOutputBytes })).not.toThrow(); expect(boundOutputToLimit(output, { ...config, maxOutputBytes })).toBe(false);
expect(() => expect(output).toEqual([{ type: "text", text: "😀 café".repeat(200) }]);
enforceOutputLimit(output, { ...config, maxOutputBytes: maxOutputBytes - 1 }),
).toThrow("code mode output limit exceeded"); expect(boundOutputToLimit(output, { ...config, maxOutputBytes: maxOutputBytes - 1 })).toBe(
true,
);
expect(JSON.stringify(output)).toContain("rerun with narrower args");
}); });
it("counts serialized output only once against the returned value", () => { it("bounds output and the returned value under one serialized budget", () => {
const output = [{ type: "text", text: "😀" }]; const output = [{ type: "text", text: "😀".repeat(200) }];
const value = { result: "café" }; const value = { result: "café".repeat(200) };
const maxOutputBytes = const maxOutputBytes =
Buffer.byteLength(JSON.stringify(output), "utf8") + Buffer.byteLength(JSON.stringify(output), "utf8") +
Buffer.byteLength(JSON.stringify(value), "utf8"); Buffer.byteLength(JSON.stringify(value), "utf8");
expect(() => expect(boundCodeModeResult({ output, value, maxOutputBytes })).toMatchObject({
enforceResultLimit({ output, value, config: { ...config, maxOutputBytes } }), output,
).not.toThrow(); value,
expect(() => truncated: false,
enforceResultLimit({ });
output,
value, const bounded = boundCodeModeResult({
config: { ...config, maxOutputBytes: maxOutputBytes - 1 }, output,
}), value,
).toThrow("code mode output limit exceeded"); maxOutputBytes: maxOutputBytes - 1,
});
expect(bounded.truncated).toBe(true);
expect(
Buffer.byteLength(JSON.stringify(bounded.output), "utf8") +
Buffer.byteLength(JSON.stringify(bounded.value), "utf8"),
).toBeLessThanOrEqual(maxOutputBytes - 1);
}); });
it("does not charge an empty output array against the returned value", () => { it("does not charge an empty output array against the returned value", () => {
const value = "ok"; const value = "ok";
const maxOutputBytes = Buffer.byteLength(JSON.stringify(value), "utf8"); const maxOutputBytes = Buffer.byteLength(JSON.stringify(value), "utf8");
expect(() => expect(boundCodeModeResult({ output: [], value, maxOutputBytes })).toMatchObject({
enforceResultLimit({ output: [], value, config: { ...config, maxOutputBytes } }), output: [],
).not.toThrow(); value,
truncated: false,
});
}); });
}); });
+14 -60
View File
@@ -6,7 +6,7 @@ import { formatErrorMessage } from "../infra/errors.js";
import { createLazyPromiseLoader } from "../shared/lazy-runtime.js"; import { createLazyPromiseLoader } from "../shared/lazy-runtime.js";
import { clampNumber } from "../utils.js"; import { clampNumber } from "../utils.js";
import { resolveAgentConfig } from "./agent-scope-config.js"; import { resolveAgentConfig } from "./agent-scope-config.js";
import { toCodeModeJsonSafe } from "./code-mode-json.js"; import { boundCodeModeResult } from "./code-mode-json.js";
import type { CodeModeNamespaceRuntime } from "./code-mode-namespaces.js"; import type { CodeModeNamespaceRuntime } from "./code-mode-namespaces.js";
import { import {
buildCodeModeScriptParseSource, buildCodeModeScriptParseSource,
@@ -241,46 +241,20 @@ export function resolveCodeModeHeadlessConfig(
>, >,
): CodeModeConfig { ): CodeModeConfig {
const base = resolveCodeModeConfig(ctx.runtimeConfig ?? ctx.config, ctx.agentId); const base = resolveCodeModeConfig(ctx.runtimeConfig ?? ctx.config, ctx.agentId);
return { const definedOverrides = Object.fromEntries(
...base, Object.entries(overrides ?? {}).filter(([, value]) => value !== undefined),
timeoutMs: clampNumber(readPositiveInteger(overrides?.timeoutMs, base.timeoutMs), 100, 60_000), );
memoryLimitBytes: clampNumber( return resolveCodeModeConfig({
readPositiveInteger(overrides?.memoryLimitBytes, base.memoryLimitBytes), tools: { codeMode: { ...base, ...definedOverrides } },
1024 * 1024, } as OpenClawConfig);
1024 * 1024 * 1024,
),
maxOutputBytes: clampNumber(
readPositiveInteger(overrides?.maxOutputBytes, base.maxOutputBytes),
1024,
10 * 1024 * 1024,
),
maxSnapshotBytes: clampNumber(
readPositiveInteger(overrides?.maxSnapshotBytes, base.maxSnapshotBytes),
1024,
256 * 1024 * 1024,
),
maxPendingToolCalls: clampNumber(
readPositiveInteger(overrides?.maxPendingToolCalls, base.maxPendingToolCalls),
1,
128,
),
};
}
function jsonByteLength(value: unknown): number {
return Buffer.byteLength(JSON.stringify(toCodeModeJsonSafe(value)) ?? "null", "utf8");
} }
class CodeModeLimitError extends ToolInputError { class CodeModeLimitError extends ToolInputError {
readonly code: Extract<CodeModeFailureCode, "output_limit_exceeded" | "snapshot_limit_exceeded">; readonly code = "snapshot_limit_exceeded" as const;
constructor( constructor(message: string) {
code: Extract<CodeModeFailureCode, "output_limit_exceeded" | "snapshot_limit_exceeded">,
message: string,
) {
super(message); super(message);
this.name = "CodeModeLimitError"; this.name = "CodeModeLimitError";
this.code = code;
} }
} }
@@ -304,28 +278,10 @@ export function codeModeFailureMessage(error: unknown): string {
: formatErrorMessage(error); : formatErrorMessage(error);
} }
export function enforceOutputLimit(output: unknown[], config: CodeModeConfig): void { export function boundOutputToLimit(output: unknown[], config: CodeModeConfig): boolean {
if (jsonByteLength(output) > config.maxOutputBytes) { const bounded = boundCodeModeResult({ output, maxOutputBytes: config.maxOutputBytes });
throw new CodeModeLimitError("output_limit_exceeded", "code mode output limit exceeded"); output.splice(0, output.length, ...bounded.output);
} return bounded.truncated;
}
export function enforceResultLimit(params: {
output: unknown[];
value?: unknown;
config: CodeModeConfig;
}): void {
const serializedOutputBytes = jsonByteLength(params.output);
if (serializedOutputBytes > params.config.maxOutputBytes) {
throw new CodeModeLimitError("output_limit_exceeded", "code mode output limit exceeded");
}
const outputBytes = params.output.length > 0 ? serializedOutputBytes : 0;
if (
params.value !== undefined &&
outputBytes + jsonByteLength(params.value) > params.config.maxOutputBytes
) {
throw new CodeModeLimitError("output_limit_exceeded", "code mode output limit exceeded");
}
} }
export function readCode(args: unknown): { export function readCode(args: unknown): {
@@ -638,12 +594,10 @@ export function createCodeModeApiFilesForRun(
export function enforceSnapshotPayloadLimits(params: { export function enforceSnapshotPayloadLimits(params: {
snapshotBytes: Uint8Array; snapshotBytes: Uint8Array;
config: CodeModeConfig; config: CodeModeConfig;
output: unknown[];
}) { }) {
if (params.snapshotBytes.byteLength > params.config.maxSnapshotBytes) { if (params.snapshotBytes.byteLength > params.config.maxSnapshotBytes) {
throw new CodeModeLimitError("snapshot_limit_exceeded", "code mode snapshot limit exceeded"); throw new CodeModeLimitError("code mode snapshot limit exceeded");
} }
enforceOutputLimit(params.output, params.config);
} }
export const codeModeRuntimeTesting = { export const codeModeRuntimeTesting = {
+2 -1
View File
@@ -271,7 +271,6 @@ export function pendingBridgeRequestsReplaySafe(
function enforceSnapshotStateLimits(params: { function enforceSnapshotStateLimits(params: {
snapshotBytes: Uint8Array; snapshotBytes: Uint8Array;
config: CodeModeConfig; config: CodeModeConfig;
output: unknown[];
reservedActiveRunSlot?: boolean; reservedActiveRunSlot?: boolean;
}) { }) {
if (!params.reservedActiveRunSlot) { if (!params.reservedActiveRunSlot) {
@@ -282,6 +281,7 @@ function enforceSnapshotStateLimits(params: {
export function createPendingBridgeStates(params: { export function createPendingBridgeStates(params: {
pendingRequests: PendingBridgeRequest[]; pendingRequests: PendingBridgeRequest[];
config: CodeModeConfig;
runtime: ToolSearchRuntime; runtime: ToolSearchRuntime;
namespaceRuntime: CodeModeNamespaceRuntime; namespaceRuntime: CodeModeNamespaceRuntime;
parentToolCallId: string; parentToolCallId: string;
@@ -305,6 +305,7 @@ export function createPendingBridgeStates(params: {
namespaceRuntime: params.namespaceRuntime, namespaceRuntime: params.namespaceRuntime,
parentToolCallId: params.parentToolCallId, parentToolCallId: params.parentToolCallId,
codeModeRunId: params.codeModeRunId, codeModeRunId: params.codeModeRunId,
maxOutputBytes: params.config.maxOutputBytes,
ctx: params.ctx, ctx: params.ctx,
request, request,
signal, signal,
+6
View File
@@ -245,6 +245,7 @@ describe("Code Mode swarm host bridge", () => {
namespaceRuntime: {}, namespaceRuntime: {},
parentToolCallId: "parent", parentToolCallId: "parent",
codeModeRunId: "cm-note", codeModeRunId: "cm-note",
maxOutputBytes: 64 * 1024,
ctx: swarmContext(), ctx: swarmContext(),
request: { request: {
id: "bridge:1", id: "bridge:1",
@@ -333,6 +334,7 @@ describe("Code Mode swarm host bridge", () => {
namespaceRuntime: {}, namespaceRuntime: {},
parentToolCallId: "parent", parentToolCallId: "parent",
codeModeRunId: restoredReplayId, codeModeRunId: restoredReplayId,
maxOutputBytes: 64 * 1024,
ctx: globalAliasContext, ctx: globalAliasContext,
}; };
@@ -408,6 +410,7 @@ describe("Code Mode swarm host bridge", () => {
runtime, runtime,
namespaceRuntime: {}, namespaceRuntime: {},
parentToolCallId: "parent", parentToolCallId: "parent",
maxOutputBytes: 64 * 1024,
ctx, ctx,
request: { id: "bridge:1", method: "agentSpawn", args: ["Research", {}] }, request: { id: "bridge:1", method: "agentSpawn", args: ["Research", {}] },
}; };
@@ -451,6 +454,7 @@ describe("Code Mode swarm host bridge", () => {
namespaceRuntime: {}, namespaceRuntime: {},
parentToolCallId: "parent", parentToolCallId: "parent",
codeModeRunId: "cm-restart", codeModeRunId: "cm-restart",
maxOutputBytes: 64 * 1024,
ctx: swarmContext(), ctx: swarmContext(),
}; };
const first = await testing.runBridgeRequest({ const first = await testing.runBridgeRequest({
@@ -496,6 +500,7 @@ describe("Code Mode swarm host bridge", () => {
namespaceRuntime: {}, namespaceRuntime: {},
parentToolCallId: "parent", parentToolCallId: "parent",
codeModeRunId: "cm-restart", codeModeRunId: "cm-restart",
maxOutputBytes: 64 * 1024,
ctx: swarmContext(), ctx: swarmContext(),
}; };
await testing.runBridgeRequest({ await testing.runBridgeRequest({
@@ -553,6 +558,7 @@ describe("Code Mode swarm host bridge", () => {
namespaceRuntime: {}, namespaceRuntime: {},
parentToolCallId: "parent", parentToolCallId: "parent",
codeModeRunId: "cm-restart", codeModeRunId: "cm-restart",
maxOutputBytes: 64 * 1024,
ctx: swarmContext(), ctx: swarmContext(),
request: { id: "bridge:1", method: "agentSpawn", args: ["Research", {}] }, request: { id: "bridge:1", method: "agentSpawn", args: ["Research", {}] },
}); });
+41 -24
View File
@@ -210,40 +210,57 @@ describe("Code Mode worker lifecycle", () => {
}); });
it.each([ it.each([
{ label: "returned values", source: 'return "x".repeat(2_048);' }, { label: "returned values", source: 'return "x".repeat(2_048);', status: "completed" },
{ label: "completed output", source: 'text("x".repeat(2_048)); return true;' }, {
label: "completed output",
source: 'text("x".repeat(2_048)); return true;',
status: "completed",
},
{ {
label: "combined output and returned values", label: "combined output and returned values",
source: 'text("x".repeat(700)); return "y".repeat(700);', source: 'text("x".repeat(700)); return "y".repeat(700);',
status: "completed",
}, },
{ {
label: "suspended output", label: "suspended output",
source: 'text("x".repeat(2_048)); await yield_control("pause"); return true;', source: 'text("x".repeat(2_048)); await yield_control("pause"); return true;',
status: "waiting",
}, },
{ label: "failed output", source: 'text("x".repeat(2_048)); throw new Error("boom");' }, {
])("rejects oversized $label before sending it across worker threads", async ({ source }) => { label: "failed output",
const config = resolveCodeModeConfig({ source: 'text("x".repeat(2_048)); throw new Error("boom");',
tools: { codeMode: { enabled: true, maxOutputBytes: 1_024 } }, status: "failed",
} as never); },
])(
"bounds oversized $label before sending it across worker threads",
async ({ source, status }) => {
const config = resolveCodeModeConfig({
tools: { codeMode: { enabled: true, maxOutputBytes: 1_024 } },
} as never);
const result = await runCodeModeWorker( const result = await runCodeModeWorker(
{ {
kind: "exec", kind: "exec",
source, source,
config, config,
catalog: [], catalog: [],
}, },
10_000, 10_000,
); );
expect(result.status).toBe("failed"); expect(result.status).toBe(status);
if (result.status !== "failed") { expect(JSON.stringify(result)).toContain("rerun with narrower args");
return; if (result.status === "failed") {
} expect(result.code).toBe("internal_error");
expect(result.code).toBe("output_limit_exceeded"); expect(result.error).toContain("boom");
expect(result.error).toBe("code mode output limit exceeded"); }
expect(result.output).toEqual([]); const outputBytes =
}); result.output.length > 0 ? Buffer.byteLength(JSON.stringify(result.output), "utf8") : 0;
const valueBytes =
result.status === "completed" ? Buffer.byteLength(JSON.stringify(result.value), "utf8") : 0;
expect(outputBytes + valueBytes).toBeLessThanOrEqual(1_024);
},
);
it("expires an idle suspended snapshot and aborts its outstanding tool", async () => { it("expires an idle suspended snapshot and aborts its outstanding tool", async () => {
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] });
-1
View File
@@ -92,7 +92,6 @@ export type CodeModeWorkerThreadResult =
| "invalid_input" | "invalid_input"
| "runtime_unavailable" | "runtime_unavailable"
| "timeout" | "timeout"
| "output_limit_exceeded"
| "snapshot_limit_exceeded" | "snapshot_limit_exceeded"
| "internal_error"; | "internal_error";
failurePhase: Extract<CodeModeFailurePhase, "input" | "guest">; failurePhase: Extract<CodeModeFailurePhase, "input" | "guest">;
+56
View File
@@ -514,6 +514,62 @@ describe("Code Mode bridge settlement and cancellation", () => {
}); });
}); });
it("returns an actionable bounded result when a nested tool result exceeds the output budget", async () => {
const catalogRef = createToolSearchCatalogRef();
const config = {
tools: { codeMode: { enabled: true, maxOutputBytes: 1_024 } },
} as never;
const ctx = {
config,
runtimeConfig: config,
sessionId: "session-code-mode",
sessionKey: "agent:main:main",
runId: "run-code-mode",
catalogRef,
};
const codeModeTools = createCodeModeTools(ctx);
const oversizedSearch = pluginToolWithExecute(
"fake_oversized_search",
"Oversized search result",
async () =>
jsonResult({
matches: [
{ path: "src/first.ts", line: 1, text: "first useful match" },
{ path: "src/large.ts", line: 2, text: "🦞".repeat(2_048) },
],
}),
);
applyCodeModeCatalog({
tools: [...codeModeTools, oversizedSearch],
config,
sessionId: "session-code-mode",
sessionKey: "agent:main:main",
runId: "run-code-mode",
catalogRef,
});
const details = resultDetails(
await expectDefined(codeModeTools[0], "Code Mode exec test invariant").execute(
"code-call-oversized-search",
{
code: 'return await tools.callValue("fake_oversized_search", {});',
},
),
);
expect(details.status).toBe("completed");
expect(oversizedSearch.execute).toHaveBeenCalledOnce();
expect(details.value).toMatchObject({
truncated: true,
omittedBytes: expect.any(Number),
guidance: expect.stringContaining("rerun with narrower args"),
prefix: expect.stringContaining("first useful match"),
});
const outputBytes = Buffer.byteLength(JSON.stringify(details.output), "utf8");
const valueBytes = Buffer.byteLength(JSON.stringify(details.value), "utf8");
expect(outputBytes + valueBytes).toBeLessThanOrEqual(1_024);
});
it("fails fast without parking a suspended run when the exec call is aborted", async () => { it("fails fast without parking a suspended run when the exec call is aborted", async () => {
const catalogRef = createToolSearchCatalogRef(); const catalogRef = createToolSearchCatalogRef();
// Long timeout so a missing abort short-circuit would block the whole test. // Long timeout so a missing abort short-circuit would block the whole test.
+27 -16
View File
@@ -25,7 +25,7 @@ describe("Code Mode runtime and output limits", () => {
resetCodeModeTestState(); resetCodeModeTestState();
}); });
it("enforces output limits on completed exec calls", async () => { it("bounds oversized values on completed exec calls", async () => {
const catalogRef = createToolSearchCatalogRef(); const catalogRef = createToolSearchCatalogRef();
const config = { const config = {
tools: { tools: {
@@ -59,12 +59,14 @@ describe("Code Mode runtime and output limits", () => {
}), }),
); );
expect(details.status).toBe("failed"); expect(details.status).toBe("completed");
expect(String(details.error)).toContain("output limit exceeded"); expect(details.value).toMatchObject({
expect(details.code).toBe("output_limit_exceeded"); truncated: true,
guidance: expect.stringContaining("rerun with narrower args"),
});
}); });
it("enforces output limits before suspending runs", async () => { it("bounds oversized output before suspending runs", async () => {
const catalogRef = createToolSearchCatalogRef(); const catalogRef = createToolSearchCatalogRef();
const config = { const config = {
tools: { tools: {
@@ -99,13 +101,21 @@ describe("Code Mode runtime and output limits", () => {
}), }),
); );
expect(details.status).toBe("failed"); expect(details.status).toBe("waiting");
expect(String(details.error)).toContain("output limit exceeded"); expect(JSON.stringify(details.output)).toContain("rerun with narrower args");
expect(details.code).toBe("output_limit_exceeded"); expect(testing.activeRuns.size).toBe(beforeRunCount + 1);
const completed = resultDetails(
await expectDefined(tools[1], "Code Mode wait test invariant").execute(
"code-wait-large-suspend",
{ runId: details.runId },
),
);
expect(completed.status).toBe("completed");
expect(testing.activeRuns.size).toBe(beforeRunCount); expect(testing.activeRuns.size).toBe(beforeRunCount);
}); });
it("enforces the cumulative output limit across yielded waits", async () => { it("bounds cumulative output across yielded waits", async () => {
const catalogRef = createToolSearchCatalogRef(); const catalogRef = createToolSearchCatalogRef();
const config = { const config = {
tools: { tools: {
@@ -157,12 +167,14 @@ describe("Code Mode runtime and output limits", () => {
), ),
); );
expect(second.status).toBe("failed"); expect(second.status).toBe("completed");
expect(second.code).toBe("output_limit_exceeded"); expect(second.value).toBe("done");
expect(JSON.stringify(second.output)).toContain("rerun with narrower args");
expect(Buffer.byteLength(JSON.stringify(second.output), "utf8")).toBeLessThanOrEqual(1_024);
expect(testing.activeRuns.has(first.runId as string)).toBe(false); expect(testing.activeRuns.has(first.runId as string)).toBe(false);
}); });
it("enforces output limits before auto-draining namespace calls", async () => { it("bounds output before auto-draining namespace calls", async () => {
const catalogRef = createToolSearchCatalogRef(); const catalogRef = createToolSearchCatalogRef();
const config = { const config = {
tools: { tools: {
@@ -206,10 +218,9 @@ describe("Code Mode runtime and output limits", () => {
), ),
); );
expect(details.status).toBe("failed"); expect(details.status).toBe("completed");
expect(String(details.error)).toContain("output limit exceeded"); expect(JSON.stringify(details.output)).toContain("rerun with narrower args");
expect(details.code).toBe("output_limit_exceeded"); expect(executeListIssues).toHaveBeenCalledOnce();
expect(executeListIssues).not.toHaveBeenCalled();
}); });
it("preserves guest output when a run fails", async () => { it("preserves guest output when a run fails", async () => {
+4 -3
View File
@@ -177,7 +177,8 @@ describe("Code Mode restart-safe replay", () => {
), ),
); );
expect(failed.status).toBe("failed"); expect(failed.status).toBe("failed");
expect(failed.error).toContain("cannot call side-effecting tools"); expect(failed.error).toContain("not proven replay-safe");
expect(failed.error).toContain("audited read, grep, or find tools");
expect(targetTool.execute).not.toHaveBeenCalled(); expect(targetTool.execute).not.toHaveBeenCalled();
}); });
@@ -217,7 +218,7 @@ describe("Code Mode restart-safe replay", () => {
bridgeDispatchStarted: true, bridgeDispatchStarted: true,
replaySafe: true, replaySafe: true,
}); });
expect(failed.error).toContain("cannot call side-effecting tools"); expect(failed.error).toContain("not proven replay-safe");
expect(readTool.execute).toHaveBeenCalledTimes(1); expect(readTool.execute).toHaveBeenCalledTimes(1);
expect(writeTool.execute).not.toHaveBeenCalled(); expect(writeTool.execute).not.toHaveBeenCalled();
}); });
@@ -262,7 +263,7 @@ describe("Code Mode restart-safe replay", () => {
), ),
); );
expect(failed.status).toBe("failed"); expect(failed.status).toBe("failed");
expect(failed.error).toContain("cannot call side-effecting tools"); expect(failed.error).toContain("not proven replay-safe");
expect(targetTool.execute).not.toHaveBeenCalled(); expect(targetTool.execute).not.toHaveBeenCalled();
}); });
}); });
+3
View File
@@ -265,6 +265,7 @@ describe("Code Mode catalog and model-visible surface", () => {
expect(parameters.properties?.restartSafe?.description).toContain( expect(parameters.properties?.restartSafe?.description).toContain(
"Leave unset for ordinary calls", "Leave unset for ordinary calls",
); );
expect(parameters.properties?.restartSafe?.description).toContain("not proven replay-safe");
expect(parameters.properties?.language?.description).toContain( expect(parameters.properties?.language?.description).toContain(
'Must be "javascript" or "typescript"', 'Must be "javascript" or "typescript"',
); );
@@ -291,6 +292,8 @@ describe("Code Mode catalog and model-visible surface", () => {
expect(execTool.description.length).toBeLessThan(2_400); expect(execTool.description.length).toBeLessThan(2_400);
expect(execTool.description).toContain("parallelize independent work only"); expect(execTool.description).toContain("parallelize independent work only");
expect(execTool.description).toContain("65536 bytes");
expect(execTool.description).toContain("rerun with narrower args");
expect(codeDescription).toEqual(expect.any(String)); expect(codeDescription).toEqual(expect.any(String));
expect(String(codeDescription).length).toBeLessThan(620); expect(String(codeDescription).length).toBeLessThan(620);
expect(codeDescription).not.toContain("MCP namespace globals"); expect(codeDescription).not.toContain("MCP namespace globals");
+6 -2
View File
@@ -161,9 +161,13 @@ function createCodeModeExecDescription(
const skillsGuidance = ctx.codeModeSkills?.length const skillsGuidance = ctx.codeModeSkills?.length
? " Skills are available through the async `skills` global: use `await skills.list()` and `await skills.read(name)`." ? " Skills are available through the async `skills` global: use `await skills.list()` and `await skills.read(name)`."
: ""; : "";
const maxOutputBytes = resolveCodeModeConfig(
ctx.runtimeConfig ?? ctx.config,
ctx.agentId,
).maxOutputBytes;
const catalogIndex = catalog ? formatCodeModeCatalogIndex(catalog) : ""; const catalogIndex = catalog ? formatCodeModeCatalogIndex(catalog) : "";
return ( 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." + `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. Nested results, output, and final value share ${maxOutputBytes} bytes; truncation reports omitted bytes and asks you to rerun with narrower args. \`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 + apiGuidance +
mcpGuidance + mcpGuidance +
swarmGuidance + swarmGuidance +
@@ -196,7 +200,7 @@ export function createCodeModeTools(ctx: CodeModeToolContext): AnyAgentTool[] {
restartSafe: Type.Optional( restartSafe: Type.Optional(
Type.Boolean({ Type.Boolean({
description: 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.", "Set true only when every catalog call is explicitly replay-safe and OpenClaw may reconstruct the work after a gateway restart. Leave unset for ordinary calls; true rejects unmarked or namespace tool surfaces not proven replay-safe.",
}), }),
), ),
}), }),
+74 -126
View File
@@ -5,7 +5,7 @@ import { parentPort, workerData } from "node:worker_threads";
import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { EvalFlags, JSException, QuickJS, type JSValueHandle } from "quickjs-wasi"; import { EvalFlags, JSException, QuickJS, type JSValueHandle } from "quickjs-wasi";
import { CODE_MODE_CONTROLLER_SOURCE } from "./code-mode-controller-source.js"; import { CODE_MODE_CONTROLLER_SOURCE } from "./code-mode-controller-source.js";
import { toCodeModeJsonSafe as toJsonSafe } from "./code-mode-json.js"; import { boundCodeModeResult, toCodeModeJsonSafe as toJsonSafe } from "./code-mode-json.js";
import type { CodeModeApiVirtualFile } from "./code-mode-namespaces.js"; import type { CodeModeApiVirtualFile } from "./code-mode-namespaces.js";
import type { import type {
CodeModeConfig, CodeModeConfig,
@@ -18,32 +18,13 @@ import type {
class CodeModeWorkerFailure extends Error { class CodeModeWorkerFailure extends Error {
readonly code: Extract<CodeModeWorkerResult, { status: "failed" }>["code"]; readonly code: Extract<CodeModeWorkerResult, { status: "failed" }>["code"];
constructor( constructor(code: Extract<CodeModeWorkerResult, { status: "failed" }>["code"], message: string) {
code: Extract<CodeModeWorkerResult, { status: "failed" }>["code"], super(message);
message: string,
options?: ErrorOptions,
) {
super(message, options);
this.name = "CodeModeWorkerFailure"; this.name = "CodeModeWorkerFailure";
this.code = code; this.code = code;
} }
} }
class CodeModeWorkerFailureWithOutput extends CodeModeWorkerFailure {
readonly output: unknown[];
constructor(
code: Extract<CodeModeWorkerResult, { status: "failed" }>["code"],
message: string,
output: unknown[],
options?: ErrorOptions,
) {
super(code, message, options);
this.name = "CodeModeWorkerFailureWithOutput";
this.output = output;
}
}
function isQuickJsInterruptedError(error: unknown): boolean { function isQuickJsInterruptedError(error: unknown): boolean {
return error instanceof JSException && error.message === "interrupted"; return error instanceof JSException && error.message === "interrupted";
} }
@@ -229,66 +210,52 @@ function takeOutputSafely(vm: QuickJS): unknown[] {
} }
} }
function enforceWorkerOutputLimit( function boundWorkerResult(
value: unknown, result: CodeModeWorkerResult,
config: CodeModeConfig, config: CodeModeConfig,
consumedBytes = 0, ): CodeModeWorkerResult {
): number { const bounded = boundCodeModeResult({
const bytes = Buffer.byteLength(JSON.stringify(toJsonSafe(value)) ?? "null", "utf8"); output: result.output,
if (consumedBytes + bytes > config.maxOutputBytes) { ...(result.status === "completed" ? { value: result.value } : {}),
throw new CodeModeWorkerFailure("output_limit_exceeded", "code mode output limit exceeded"); maxOutputBytes: config.maxOutputBytes,
});
if (result.status === "completed") {
return { ...result, output: bounded.output, value: bounded.value };
} }
return bytes; return { ...result, output: bounded.output };
} }
function throwWorkerFailureWithOutput(params: { function failedWorkerResult(
code: Extract<CodeModeWorkerResult, { status: "failed" }>["code"],
error: string,
output: unknown[] = [],
): Extract<CodeModeWorkerResult, { status: "failed" }> {
return {
status: "failed",
code,
error,
failurePhase: code === "invalid_input" ? "input" : "guest",
bridgeDispatchStarted: false,
output,
};
}
function workerFailureResult(params: {
error: unknown; error: unknown;
didTimeout: () => boolean; didTimeout: () => boolean;
output: unknown[]; output: unknown[];
vm: QuickJS; vm: QuickJS;
config: CodeModeConfig; }): CodeModeWorkerResult {
}): never {
const timedOut = params.didTimeout() || isQuickJsInterruptedError(params.error); const timedOut = params.didTimeout() || isQuickJsInterruptedError(params.error);
const failureOutput = params.output.length > 0 ? params.output : takeOutputSafely(params.vm); const output = params.output.length > 0 ? params.output : takeOutputSafely(params.vm);
if (
params.error instanceof CodeModeWorkerFailure &&
params.error.code === "output_limit_exceeded"
) {
throw new CodeModeWorkerFailureWithOutput(params.error.code, params.error.message, [], {
cause: params.error,
});
}
try {
enforceWorkerOutputLimit(failureOutput, params.config);
} catch (error) {
if (error instanceof CodeModeWorkerFailure) {
throw new CodeModeWorkerFailureWithOutput(error.code, error.message, [], { cause: error });
}
throw error;
}
if (timedOut) { if (timedOut) {
throw new CodeModeWorkerFailureWithOutput( return failedWorkerResult("timeout", "code mode timeout exceeded", output);
"timeout",
"code mode timeout exceeded",
failureOutput,
{ cause: params.error },
);
} }
if (params.error instanceof CodeModeWorkerFailure) { if (params.error instanceof CodeModeWorkerFailure) {
throw new CodeModeWorkerFailureWithOutput( return failedWorkerResult(params.error.code, params.error.message, output);
params.error.code,
params.error.message,
failureOutput,
{ cause: params.error },
);
} }
if (failureOutput.length > 0) { if (output.length > 0) {
throw new CodeModeWorkerFailureWithOutput( return failedWorkerResult("internal_error", errorMessage(params.error), output);
"internal_error",
errorMessage(params.error),
failureOutput,
{ cause: params.error },
);
} }
throw params.error; throw params.error;
} }
@@ -356,7 +323,6 @@ async function runVmExecution(params: {
params.prepare(); params.prepare();
params.vm.executePendingJobs(); params.vm.executePendingJobs();
output = takeOutput(params.vm); output = takeOutput(params.vm);
const outputBytes = enforceWorkerOutputLimit(output, params.config);
const resultHandle = params.vm.global.getProp("__openclawResult"); const resultHandle = params.vm.global.getProp("__openclawResult");
try { try {
const promisePending = resultHandle.isPromise && resultHandle.promiseState === 0; const promisePending = resultHandle.isPromise && resultHandle.promiseState === 0;
@@ -378,22 +344,16 @@ async function runVmExecution(params: {
}); });
} }
const value = await readCompletedResult(params.vm, resultHandle); const value = await readCompletedResult(params.vm, resultHandle);
enforceWorkerOutputLimit(value, params.config, output.length > 0 ? outputBytes : 0); return { status: "completed", value, output };
return {
status: "completed",
value,
output,
};
} finally { } finally {
resultHandle.dispose(); resultHandle.dispose();
} }
} catch (error) { } catch (error) {
return throwWorkerFailureWithOutput({ return workerFailureResult({
error, error,
didTimeout: params.didTimeout, didTimeout: params.didTimeout,
output, output,
vm: params.vm, vm: params.vm,
config: params.config,
}); });
} finally { } finally {
params.vm.dispose(); params.vm.dispose();
@@ -471,52 +431,47 @@ function isQuickJsWasmModule(value: unknown): value is WebAssembly.Module {
async function main(): Promise<CodeModeWorkerResult> { async function main(): Promise<CodeModeWorkerResult> {
const input = workerData as unknown; const input = workerData as unknown;
if (!isRecord(input) || !isRecord(input.config) || !isQuickJsWasmModule(input.wasmModule)) { if (!isRecord(input) || !isRecord(input.config) || !isQuickJsWasmModule(input.wasmModule)) {
return { return failedWorkerResult("invalid_input", "invalid code mode worker input");
status: "failed",
error: "invalid code mode worker input",
code: "invalid_input",
failurePhase: "input",
bridgeDispatchStarted: false,
output: [],
};
} }
const config = input.config as CodeModeConfig;
try { try {
if (input.kind === "exec" && typeof input.source === "string") { if (input.kind === "exec" && typeof input.source === "string") {
return await runExec({ return boundWorkerResult(
kind: "exec", await runExec({
wasmModule: input.wasmModule, kind: "exec",
source: input.source, wasmModule: input.wasmModule,
config: input.config as CodeModeConfig, source: input.source,
catalog: Array.isArray(input.catalog) ? input.catalog : [], config,
apiFiles: Array.isArray(input.apiFiles) ? (input.apiFiles as CodeModeApiVirtualFile[]) : [], catalog: Array.isArray(input.catalog) ? input.catalog : [],
namespaces: Array.isArray(input.namespaces) apiFiles: Array.isArray(input.apiFiles)
? (input.namespaces as CodeModeNamespaceDescriptor[]) ? (input.apiFiles as CodeModeApiVirtualFile[])
: [], : [],
swarmEnabled: input.swarmEnabled === true, namespaces: Array.isArray(input.namespaces)
}); ? (input.namespaces as CodeModeNamespaceDescriptor[])
: [],
swarmEnabled: input.swarmEnabled === true,
}),
config,
);
} }
if (input.kind === "resume" && input.snapshotBytes instanceof Uint8Array) { if (input.kind === "resume" && input.snapshotBytes instanceof Uint8Array) {
return await runResume({ return boundWorkerResult(
kind: "resume", await runResume({
wasmModule: input.wasmModule, kind: "resume",
snapshotBytes: input.snapshotBytes, wasmModule: input.wasmModule,
config: input.config as CodeModeConfig, snapshotBytes: input.snapshotBytes,
settledRequests: Array.isArray(input.settledRequests) config,
? (input.settledRequests as SettledBridgeRequest[]) settledRequests: Array.isArray(input.settledRequests)
: [], ? (input.settledRequests as SettledBridgeRequest[])
pendingRequests: Array.isArray(input.pendingRequests) : [],
? (input.pendingRequests as PendingBridgeRequest[]) pendingRequests: Array.isArray(input.pendingRequests)
: [], ? (input.pendingRequests as PendingBridgeRequest[])
}); : [],
}),
config,
);
} }
return { return failedWorkerResult("invalid_input", "invalid code mode worker input");
status: "failed",
error: "invalid code mode worker input",
code: "invalid_input",
failurePhase: "input",
bridgeDispatchStarted: false,
output: [],
};
} catch (error) { } catch (error) {
const timedOut = isQuickJsInterruptedError(error); const timedOut = isQuickJsInterruptedError(error);
const code = timedOut const code = timedOut
@@ -524,14 +479,7 @@ async function main(): Promise<CodeModeWorkerResult> {
: error instanceof CodeModeWorkerFailure : error instanceof CodeModeWorkerFailure
? error.code ? error.code
: "internal_error"; : "internal_error";
return { return failedWorkerResult(code, timedOut ? "code mode timeout exceeded" : errorMessage(error));
status: "failed",
error: timedOut ? "code mode timeout exceeded" : errorMessage(error),
code,
failurePhase: code === "invalid_input" ? "input" : "guest",
bridgeDispatchStarted: false,
output: error instanceof CodeModeWorkerFailureWithOutput ? error.output : [],
};
} }
} }