From e6ca5266afe1aa4932f6fee69def268bb7274095 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 19:24:18 -0700 Subject: [PATCH] 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 --- docs/tools/code-mode.md | 22 +- src/agents/code-mode-bridge.ts | 9 +- src/agents/code-mode-execution.ts | 30 +-- src/agents/code-mode-headless.test.ts | 28 +-- src/agents/code-mode-headless.ts | 23 +- src/agents/code-mode-json.ts | 75 +++++++ src/agents/code-mode-runtime.test.ts | 61 +++--- src/agents/code-mode-runtime.ts | 74 ++----- src/agents/code-mode-state.ts | 3 +- src/agents/code-mode-swarm.test.ts | 6 + src/agents/code-mode-worker-lifecycle.test.ts | 65 +++--- src/agents/code-mode-worker-types.ts | 1 - src/agents/code-mode.bridge.test.ts | 56 +++++ src/agents/code-mode.limits.test.ts | 43 ++-- src/agents/code-mode.replay.test.ts | 7 +- src/agents/code-mode.test.ts | 3 + src/agents/code-mode.ts | 8 +- src/agents/code-mode.worker.ts | 200 +++++++----------- 18 files changed, 409 insertions(+), 305 deletions(-) diff --git a/docs/tools/code-mode.md b/docs/tools/code-mode.md index fd36f1e4bb72..b33b1be94f9c 100644 --- a/docs/tools/code-mode.md +++ b/docs/tools/code-mode.md @@ -796,10 +796,15 @@ the bridge as JSON-compatible values with explicit size caps. type CodeModeOutput = { type: "text"; text: string } | { type: "json"; value: unknown }; ``` -Rules: output order matches guest calls; output is capped by -`maxOutputBytes`; 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. +Rules: output order matches guest calls. Nested tool results, cumulative guest +output, and the final value share the `maxOutputBytes` serialized UTF-8 budget. +When a successful result exceeds the budget, OpenClaw returns a bounded value +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 @@ -905,8 +910,10 @@ session.`. `completed` or `failed`, or is dropped on Gateway shutdown (nothing survives a restart: this is transient runtime state). - For read-only work, `exec` can set `restartSafe: true`. OpenClaw then rejects - side-effecting catalog and namespace tool calls before execution and - marks suspended results as replay-safe. If a restart interrupts `wait`, + catalog and namespace tool surfaces that are not proven replay-safe before + 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 transcript instead of restoring the process-local snapshot. The recovery 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/ wrong-scope `runId` values, and too many suspended runs. `runtime_unavailable` 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 objects, prototypes, and host functions do not cross into QuickJS. diff --git a/src/agents/code-mode-bridge.ts b/src/agents/code-mode-bridge.ts index e4422f168fd9..dc3ce00a9c18 100644 --- a/src/agents/code-mode-bridge.ts +++ b/src/agents/code-mode-bridge.ts @@ -6,7 +6,7 @@ import { NODE_FS_LIST_DIR_COMMAND } from "../infra/node-commands.js"; import { emitSessionLifecycleEvent } from "../sessions/session-lifecycle-events.js"; import { parseNodeList } from "../shared/node-list-parse.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 { PendingBridgeRequest, SettledBridgeRequest } from "./code-mode-runtime.js"; import { readCodeModeSkill } from "./code-mode-skills.js"; @@ -393,6 +393,7 @@ export async function runBridgeRequest(params: { namespaceRuntime: CodeModeNamespaceRuntime; parentToolCallId: string; codeModeRunId: string; + maxOutputBytes: number; ctx: ToolSearchToolContext; request: PendingBridgeRequest; signal?: AbortSignal; @@ -542,7 +543,11 @@ export async function runBridgeRequest(params: { 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) { return { id: params.request.id, ok: false, error: formatErrorMessage(error) }; } diff --git a/src/agents/code-mode-execution.ts b/src/agents/code-mode-execution.ts index dd75947441e8..39ed27bad6ce 100644 --- a/src/agents/code-mode-execution.ts +++ b/src/agents/code-mode-execution.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { codeModeReplayIdForToolCall } from "./code-mode-bridge.js"; import { awaitCodeModeDeadline } from "./code-mode-deadline.js"; +import { boundCodeModeResult } from "./code-mode-json.js"; import { createCodeModeNamespaceRuntime, type CodeModeNamespaceRuntime, @@ -10,8 +11,7 @@ import { codeModeFailureCode, codeModeFailureMessage, createCodeModeApiFilesForRun, - enforceOutputLimit, - enforceResultLimit, + boundOutputToLimit, enforceSnapshotPayloadLimits, prepareSource, resolveCodeModeConfig, @@ -240,7 +240,7 @@ async function settleCodeModeResult(params: { let pending = params.pending ?? []; const activeRunId = params.activeRunId ?? `cm_${randomUUID()}`; 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 // 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 @@ -300,7 +300,6 @@ async function settleCodeModeResult(params: { enforceSnapshotPayloadLimits({ snapshotBytes: result.snapshotBytes, config: params.config, - output, }); if (!params.reservedActiveRunSlot) { releaseReservation = reserveActiveRunSlot(); @@ -317,6 +316,7 @@ async function settleCodeModeResult(params: { pending.push( ...createPendingBridgeStates({ pendingRequests: newPendingRequests, + config: params.config, runtime: params.runtime, namespaceRuntime: params.namespaceRuntime, parentToolCallId: params.parentToolCallId, @@ -388,7 +388,9 @@ async function settleCodeModeResult(params: { ), ); output.push(...result.output); - enforceOutputLimit(output, params.config); + if (boundOutputToLimit(output, params.config)) { + deliveredOutputCount = 0; + } } catch (error) { cancelPendingBridgeStates(pending); throw error; @@ -409,7 +411,8 @@ async function settleCodeModeResult(params: { cancelPendingBridgeStates(pending); return { 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, failurePhase: params.bridgeDispatch.started ? ("bridge" as const) : ("input" as const), bridgeDispatchStarted: params.bridgeDispatch.started, @@ -426,7 +429,6 @@ async function settleCodeModeResult(params: { enforceSnapshotPayloadLimits({ snapshotBytes: result.snapshotBytes, config: params.config, - output, }); // Reserve before launching fresh work; transferred snapshots must // obey the same process-wide active-run cap as initial suspensions. @@ -443,6 +445,7 @@ async function settleCodeModeResult(params: { pending.push( ...createPendingBridgeStates({ pendingRequests: newPendingRequests, + config: params.config, runtime: params.runtime, namespaceRuntime: params.namespaceRuntime, parentToolCallId: params.parentToolCallId, @@ -499,20 +502,21 @@ async function settleCodeModeResult(params: { // Defensive cleanup covers aborts or terminal failures; successful runs have // already drained every dispatched call before releasing their snapshot. cancelPendingBridgeStates(pending); - enforceResultLimit({ + const bounded = boundCodeModeResult({ output, - value: result.status === "completed" ? result.value : undefined, - config: params.config, + ...(result.status === "completed" ? { value: result.value } : {}), + maxOutputBytes: params.config.maxOutputBytes, }); return { ...result, + ...(result.status === "completed" ? { value: bounded.value } : {}), ...(result.status === "failed" ? { failurePhase: params.bridgeDispatch.started ? ("bridge" as const) : result.failurePhase, bridgeDispatchStarted: params.bridgeDispatch.started, } : {}), - output: output.slice(deliveredOutputCount), + output: bounded.output.slice(bounded.truncated ? 0 : deliveredOutputCount), replaySafe: params.replaySafe, telemetry: telemetry(params.runtime), }; @@ -616,7 +620,7 @@ export async function runWait(params: { ), ); const output = [...state.output, ...result.output]; - enforceOutputLimit(output, state.config); + const outputTruncated = boundOutputToLimit(output, state.config); return await settleCodeModeResult({ result, output, @@ -629,7 +633,7 @@ export async function runWait(params: { runtime: state.runtime, namespaceRuntime: state.namespaceRuntime, bridgeDispatch: { started: true }, - deliveredOutputCount: state.deliveredOutputCount, + deliveredOutputCount: outputTruncated ? 0 : state.deliveredOutputCount, pending, activeRunId: state.runId, reservedActiveRunSlot: true, diff --git a/src/agents/code-mode-headless.test.ts b/src/agents/code-mode-headless.test.ts index 9769c15e3893..1aa1b239960d 100644 --- a/src/agents/code-mode-headless.test.ts +++ b/src/agents/code-mode-headless.test.ts @@ -650,7 +650,7 @@ describe("headless Code Mode", () => { it("bounds output and returned values across separate worker legs", async () => { const tool = fakeTool("output_boundary", async () => jsonResult({ ok: true })); - const result = expectFailed( + const result = expectCompleted( await runCodeModeScriptHeadless({ ctx: createHeadlessHarness([tool]), 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(); }); @@ -891,29 +895,15 @@ describe("headless Code Mode", () => { } }); - it.each([ - { - 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 }) => { + it("classifies syntax errors", async () => { const result = expectFailed( await runCodeModeScriptHeadless({ ctx: createHeadlessHarness(), - code, - overrides, + code: "return (;", }), ); - expect(result.code).toBe(expectedCode); + expect(result.code).toBe("internal_error"); }); it("clamps headless limit overrides to worker-safe bounds", () => { diff --git a/src/agents/code-mode-headless.ts b/src/agents/code-mode-headless.ts index f45d8d75fa2c..900e9ea63133 100644 --- a/src/agents/code-mode-headless.ts +++ b/src/agents/code-mode-headless.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { clampNumber } from "../utils.js"; import { awaitCodeModeDeadline } from "./code-mode-deadline.js"; -import { toCodeModeJsonSafe } from "./code-mode-json.js"; +import { boundCodeModeResult, toCodeModeJsonSafe } from "./code-mode-json.js"; import { createCodeModeNamespaceRuntime, type CodeModeNamespaceDescriptor, @@ -16,8 +16,7 @@ import { codeModeFailureCode, codeModeFailureMessage, createCodeModeApiFilesForRun, - enforceOutputLimit, - enforceResultLimit, + boundOutputToLimit, enforceSnapshotPayloadLimits, prepareSource, readPositiveInteger, @@ -253,10 +252,19 @@ export async function runCodeModeScriptHeadless(params: { while (true) { output.push(...result.output); - enforceOutputLimit(output, config); + boundOutputToLimit(output, config); if (result.status === "completed") { - enforceResultLimit({ output, value: result.value, config }); - return { status: "completed", value: result.value, output, toolCallCount }; + const bounded = boundCodeModeResult({ + output, + value: result.value, + maxOutputBytes: config.maxOutputBytes, + }); + return { + status: "completed", + value: bounded.value, + output: bounded.output, + toolCallCount, + }; } if (result.status === "failed") { 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 newRequests = result.pendingRequests.filter((request) => !pendingIds.has(request.id)); // Node discovery invokes the generic nodes tool for live status too; @@ -292,6 +300,7 @@ export async function runCodeModeScriptHeadless(params: { pending.push( ...createPendingBridgeStates({ pendingRequests: newRequests, + config, runtime, namespaceRuntime, parentToolCallId, diff --git a/src/agents/code-mode-json.ts b/src/agents/code-mode-json.ts index a0b059f5f91d..df6861056db3 100644 --- a/src/agents/code-mode-json.ts +++ b/src/agents/code-mode-json.ts @@ -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 { if (value === undefined) { 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 }; +} diff --git a/src/agents/code-mode-runtime.test.ts b/src/agents/code-mode-runtime.test.ts index b0992642156b..cbe08813e99d 100644 --- a/src/agents/code-mode-runtime.test.ts +++ b/src/agents/code-mode-runtime.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; +import { boundCodeModeResult } from "./code-mode-json.js"; import { - enforceOutputLimit, - enforceResultLimit, + boundOutputToLimit, isCodeModeEngagedForModel, prepareSource, resolveCodeModeConfig, @@ -10,43 +10,54 @@ import { parseCodeModeScriptSyntax } from "./code-mode-script-syntax.js"; const config = resolveCodeModeConfig({ tools: { codeMode: true } } as never); -describe("Code Mode output accounting", () => { - it("accepts Unicode output at its exact serialized byte limit", () => { - const output = [{ type: "text", text: "😀 café" }]; +describe("Code Mode output bounding", () => { + it("preserves Unicode output at its exact serialized byte limit", () => { + const output = [{ type: "text", text: "😀 café".repeat(200) }]; const maxOutputBytes = Buffer.byteLength(JSON.stringify(output), "utf8"); - expect(() => enforceOutputLimit(output, { ...config, maxOutputBytes })).not.toThrow(); - expect(() => - enforceOutputLimit(output, { ...config, maxOutputBytes: maxOutputBytes - 1 }), - ).toThrow("code mode output limit exceeded"); + expect(boundOutputToLimit(output, { ...config, maxOutputBytes })).toBe(false); + expect(output).toEqual([{ type: "text", text: "😀 café".repeat(200) }]); + + 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", () => { - const output = [{ type: "text", text: "😀" }]; - const value = { result: "café" }; + it("bounds output and the returned value under one serialized budget", () => { + const output = [{ type: "text", text: "😀".repeat(200) }]; + const value = { result: "café".repeat(200) }; const maxOutputBytes = Buffer.byteLength(JSON.stringify(output), "utf8") + Buffer.byteLength(JSON.stringify(value), "utf8"); - expect(() => - enforceResultLimit({ output, value, config: { ...config, maxOutputBytes } }), - ).not.toThrow(); - expect(() => - enforceResultLimit({ - output, - value, - config: { ...config, maxOutputBytes: maxOutputBytes - 1 }, - }), - ).toThrow("code mode output limit exceeded"); + expect(boundCodeModeResult({ output, value, maxOutputBytes })).toMatchObject({ + output, + value, + truncated: false, + }); + + const bounded = boundCodeModeResult({ + output, + value, + 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", () => { const value = "ok"; const maxOutputBytes = Buffer.byteLength(JSON.stringify(value), "utf8"); - expect(() => - enforceResultLimit({ output: [], value, config: { ...config, maxOutputBytes } }), - ).not.toThrow(); + expect(boundCodeModeResult({ output: [], value, maxOutputBytes })).toMatchObject({ + output: [], + value, + truncated: false, + }); }); }); diff --git a/src/agents/code-mode-runtime.ts b/src/agents/code-mode-runtime.ts index 3847cb3e4405..8821e54a9cba 100644 --- a/src/agents/code-mode-runtime.ts +++ b/src/agents/code-mode-runtime.ts @@ -6,7 +6,7 @@ import { formatErrorMessage } from "../infra/errors.js"; import { createLazyPromiseLoader } from "../shared/lazy-runtime.js"; import { clampNumber } from "../utils.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 { buildCodeModeScriptParseSource, @@ -241,46 +241,20 @@ export function resolveCodeModeHeadlessConfig( >, ): CodeModeConfig { const base = resolveCodeModeConfig(ctx.runtimeConfig ?? ctx.config, ctx.agentId); - return { - ...base, - timeoutMs: clampNumber(readPositiveInteger(overrides?.timeoutMs, base.timeoutMs), 100, 60_000), - memoryLimitBytes: clampNumber( - readPositiveInteger(overrides?.memoryLimitBytes, base.memoryLimitBytes), - 1024 * 1024, - 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"); + const definedOverrides = Object.fromEntries( + Object.entries(overrides ?? {}).filter(([, value]) => value !== undefined), + ); + return resolveCodeModeConfig({ + tools: { codeMode: { ...base, ...definedOverrides } }, + } as OpenClawConfig); } class CodeModeLimitError extends ToolInputError { - readonly code: Extract; + readonly code = "snapshot_limit_exceeded" as const; - constructor( - code: Extract, - message: string, - ) { + constructor(message: string) { super(message); this.name = "CodeModeLimitError"; - this.code = code; } } @@ -304,28 +278,10 @@ export function codeModeFailureMessage(error: unknown): string { : formatErrorMessage(error); } -export function enforceOutputLimit(output: unknown[], config: CodeModeConfig): void { - if (jsonByteLength(output) > config.maxOutputBytes) { - throw new CodeModeLimitError("output_limit_exceeded", "code mode output limit exceeded"); - } -} - -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 boundOutputToLimit(output: unknown[], config: CodeModeConfig): boolean { + const bounded = boundCodeModeResult({ output, maxOutputBytes: config.maxOutputBytes }); + output.splice(0, output.length, ...bounded.output); + return bounded.truncated; } export function readCode(args: unknown): { @@ -638,12 +594,10 @@ export function createCodeModeApiFilesForRun( export 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"); + throw new CodeModeLimitError("code mode snapshot limit exceeded"); } - enforceOutputLimit(params.output, params.config); } export const codeModeRuntimeTesting = { diff --git a/src/agents/code-mode-state.ts b/src/agents/code-mode-state.ts index 953cc146345e..8885e834e8ed 100644 --- a/src/agents/code-mode-state.ts +++ b/src/agents/code-mode-state.ts @@ -271,7 +271,6 @@ export function pendingBridgeRequestsReplaySafe( function enforceSnapshotStateLimits(params: { snapshotBytes: Uint8Array; config: CodeModeConfig; - output: unknown[]; reservedActiveRunSlot?: boolean; }) { if (!params.reservedActiveRunSlot) { @@ -282,6 +281,7 @@ function enforceSnapshotStateLimits(params: { export function createPendingBridgeStates(params: { pendingRequests: PendingBridgeRequest[]; + config: CodeModeConfig; runtime: ToolSearchRuntime; namespaceRuntime: CodeModeNamespaceRuntime; parentToolCallId: string; @@ -305,6 +305,7 @@ export function createPendingBridgeStates(params: { namespaceRuntime: params.namespaceRuntime, parentToolCallId: params.parentToolCallId, codeModeRunId: params.codeModeRunId, + maxOutputBytes: params.config.maxOutputBytes, ctx: params.ctx, request, signal, diff --git a/src/agents/code-mode-swarm.test.ts b/src/agents/code-mode-swarm.test.ts index 1fb491d8e9aa..4cf704582c25 100644 --- a/src/agents/code-mode-swarm.test.ts +++ b/src/agents/code-mode-swarm.test.ts @@ -245,6 +245,7 @@ describe("Code Mode swarm host bridge", () => { namespaceRuntime: {}, parentToolCallId: "parent", codeModeRunId: "cm-note", + maxOutputBytes: 64 * 1024, ctx: swarmContext(), request: { id: "bridge:1", @@ -333,6 +334,7 @@ describe("Code Mode swarm host bridge", () => { namespaceRuntime: {}, parentToolCallId: "parent", codeModeRunId: restoredReplayId, + maxOutputBytes: 64 * 1024, ctx: globalAliasContext, }; @@ -408,6 +410,7 @@ describe("Code Mode swarm host bridge", () => { runtime, namespaceRuntime: {}, parentToolCallId: "parent", + maxOutputBytes: 64 * 1024, ctx, request: { id: "bridge:1", method: "agentSpawn", args: ["Research", {}] }, }; @@ -451,6 +454,7 @@ describe("Code Mode swarm host bridge", () => { namespaceRuntime: {}, parentToolCallId: "parent", codeModeRunId: "cm-restart", + maxOutputBytes: 64 * 1024, ctx: swarmContext(), }; const first = await testing.runBridgeRequest({ @@ -496,6 +500,7 @@ describe("Code Mode swarm host bridge", () => { namespaceRuntime: {}, parentToolCallId: "parent", codeModeRunId: "cm-restart", + maxOutputBytes: 64 * 1024, ctx: swarmContext(), }; await testing.runBridgeRequest({ @@ -553,6 +558,7 @@ describe("Code Mode swarm host bridge", () => { namespaceRuntime: {}, parentToolCallId: "parent", codeModeRunId: "cm-restart", + maxOutputBytes: 64 * 1024, ctx: swarmContext(), request: { id: "bridge:1", method: "agentSpawn", args: ["Research", {}] }, }); diff --git a/src/agents/code-mode-worker-lifecycle.test.ts b/src/agents/code-mode-worker-lifecycle.test.ts index 69b96467a92b..35d55190662d 100644 --- a/src/agents/code-mode-worker-lifecycle.test.ts +++ b/src/agents/code-mode-worker-lifecycle.test.ts @@ -210,40 +210,57 @@ describe("Code Mode worker lifecycle", () => { }); it.each([ - { label: "returned values", source: 'return "x".repeat(2_048);' }, - { label: "completed output", source: 'text("x".repeat(2_048)); return true;' }, + { label: "returned values", source: 'return "x".repeat(2_048);', status: "completed" }, + { + label: "completed output", + source: 'text("x".repeat(2_048)); return true;', + status: "completed", + }, { label: "combined output and returned values", source: 'text("x".repeat(700)); return "y".repeat(700);', + status: "completed", }, { label: "suspended output", 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 }) => { - const config = resolveCodeModeConfig({ - tools: { codeMode: { enabled: true, maxOutputBytes: 1_024 } }, - } as never); + { + label: "failed output", + source: 'text("x".repeat(2_048)); throw new Error("boom");', + status: "failed", + }, + ])( + "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( - { - kind: "exec", - source, - config, - catalog: [], - }, - 10_000, - ); + const result = await runCodeModeWorker( + { + kind: "exec", + source, + config, + catalog: [], + }, + 10_000, + ); - expect(result.status).toBe("failed"); - if (result.status !== "failed") { - return; - } - expect(result.code).toBe("output_limit_exceeded"); - expect(result.error).toBe("code mode output limit exceeded"); - expect(result.output).toEqual([]); - }); + expect(result.status).toBe(status); + expect(JSON.stringify(result)).toContain("rerun with narrower args"); + if (result.status === "failed") { + expect(result.code).toBe("internal_error"); + expect(result.error).toContain("boom"); + } + 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 () => { vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); diff --git a/src/agents/code-mode-worker-types.ts b/src/agents/code-mode-worker-types.ts index d4d7b5030e5f..38ce969bfc12 100644 --- a/src/agents/code-mode-worker-types.ts +++ b/src/agents/code-mode-worker-types.ts @@ -92,7 +92,6 @@ export type CodeModeWorkerThreadResult = | "invalid_input" | "runtime_unavailable" | "timeout" - | "output_limit_exceeded" | "snapshot_limit_exceeded" | "internal_error"; failurePhase: Extract; diff --git a/src/agents/code-mode.bridge.test.ts b/src/agents/code-mode.bridge.test.ts index 5fc3aa1be8f7..69403e22227d 100644 --- a/src/agents/code-mode.bridge.test.ts +++ b/src/agents/code-mode.bridge.test.ts @@ -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 () => { const catalogRef = createToolSearchCatalogRef(); // Long timeout so a missing abort short-circuit would block the whole test. diff --git a/src/agents/code-mode.limits.test.ts b/src/agents/code-mode.limits.test.ts index ba97c35c0a11..f39241484f50 100644 --- a/src/agents/code-mode.limits.test.ts +++ b/src/agents/code-mode.limits.test.ts @@ -25,7 +25,7 @@ describe("Code Mode runtime and output limits", () => { resetCodeModeTestState(); }); - it("enforces output limits on completed exec calls", async () => { + it("bounds oversized values on completed exec calls", async () => { const catalogRef = createToolSearchCatalogRef(); const config = { tools: { @@ -59,12 +59,14 @@ describe("Code Mode runtime and output limits", () => { }), ); - expect(details.status).toBe("failed"); - expect(String(details.error)).toContain("output limit exceeded"); - expect(details.code).toBe("output_limit_exceeded"); + expect(details.status).toBe("completed"); + expect(details.value).toMatchObject({ + 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 config = { tools: { @@ -99,13 +101,21 @@ describe("Code Mode runtime and output limits", () => { }), ); - expect(details.status).toBe("failed"); - expect(String(details.error)).toContain("output limit exceeded"); - expect(details.code).toBe("output_limit_exceeded"); + expect(details.status).toBe("waiting"); + expect(JSON.stringify(details.output)).toContain("rerun with narrower args"); + 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); }); - it("enforces the cumulative output limit across yielded waits", async () => { + it("bounds cumulative output across yielded waits", async () => { const catalogRef = createToolSearchCatalogRef(); const config = { tools: { @@ -157,12 +167,14 @@ describe("Code Mode runtime and output limits", () => { ), ); - expect(second.status).toBe("failed"); - expect(second.code).toBe("output_limit_exceeded"); + expect(second.status).toBe("completed"); + 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); }); - it("enforces output limits before auto-draining namespace calls", async () => { + it("bounds output before auto-draining namespace calls", async () => { const catalogRef = createToolSearchCatalogRef(); const config = { tools: { @@ -206,10 +218,9 @@ describe("Code Mode runtime and output limits", () => { ), ); - expect(details.status).toBe("failed"); - expect(String(details.error)).toContain("output limit exceeded"); - expect(details.code).toBe("output_limit_exceeded"); - expect(executeListIssues).not.toHaveBeenCalled(); + expect(details.status).toBe("completed"); + expect(JSON.stringify(details.output)).toContain("rerun with narrower args"); + expect(executeListIssues).toHaveBeenCalledOnce(); }); it("preserves guest output when a run fails", async () => { diff --git a/src/agents/code-mode.replay.test.ts b/src/agents/code-mode.replay.test.ts index 37d78a8aa4ab..ae292ddc1653 100644 --- a/src/agents/code-mode.replay.test.ts +++ b/src/agents/code-mode.replay.test.ts @@ -177,7 +177,8 @@ describe("Code Mode restart-safe replay", () => { ), ); 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(); }); @@ -217,7 +218,7 @@ describe("Code Mode restart-safe replay", () => { bridgeDispatchStarted: 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(writeTool.execute).not.toHaveBeenCalled(); }); @@ -262,7 +263,7 @@ describe("Code Mode restart-safe replay", () => { ), ); 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(); }); }); diff --git a/src/agents/code-mode.test.ts b/src/agents/code-mode.test.ts index 2563b9ec8e61..17e20fce54c3 100644 --- a/src/agents/code-mode.test.ts +++ b/src/agents/code-mode.test.ts @@ -265,6 +265,7 @@ describe("Code Mode catalog and model-visible surface", () => { expect(parameters.properties?.restartSafe?.description).toContain( "Leave unset for ordinary calls", ); + expect(parameters.properties?.restartSafe?.description).toContain("not proven replay-safe"); expect(parameters.properties?.language?.description).toContain( '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).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(String(codeDescription).length).toBeLessThan(620); expect(codeDescription).not.toContain("MCP namespace globals"); diff --git a/src/agents/code-mode.ts b/src/agents/code-mode.ts index 36a0612e0330..f2d8582fb69a 100644 --- a/src/agents/code-mode.ts +++ b/src/agents/code-mode.ts @@ -161,9 +161,13 @@ function createCodeModeExecDescription( const skillsGuidance = ctx.codeModeSkills?.length ? " 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) : ""; 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 + mcpGuidance + swarmGuidance + @@ -196,7 +200,7 @@ export function createCodeModeTools(ctx: CodeModeToolContext): AnyAgentTool[] { 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.", + "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.", }), ), }), diff --git a/src/agents/code-mode.worker.ts b/src/agents/code-mode.worker.ts index 5b974617311d..aa87a646d3c6 100644 --- a/src/agents/code-mode.worker.ts +++ b/src/agents/code-mode.worker.ts @@ -5,7 +5,7 @@ import { parentPort, workerData } from "node:worker_threads"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { EvalFlags, JSException, QuickJS, type JSValueHandle } from "quickjs-wasi"; 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 { CodeModeConfig, @@ -18,32 +18,13 @@ import type { class CodeModeWorkerFailure extends Error { readonly code: Extract["code"]; - constructor( - code: Extract["code"], - message: string, - options?: ErrorOptions, - ) { - super(message, options); + constructor(code: Extract["code"], message: string) { + super(message); this.name = "CodeModeWorkerFailure"; this.code = code; } } -class CodeModeWorkerFailureWithOutput extends CodeModeWorkerFailure { - readonly output: unknown[]; - - constructor( - code: Extract["code"], - message: string, - output: unknown[], - options?: ErrorOptions, - ) { - super(code, message, options); - this.name = "CodeModeWorkerFailureWithOutput"; - this.output = output; - } -} - function isQuickJsInterruptedError(error: unknown): boolean { return error instanceof JSException && error.message === "interrupted"; } @@ -229,66 +210,52 @@ function takeOutputSafely(vm: QuickJS): unknown[] { } } -function enforceWorkerOutputLimit( - value: unknown, +function boundWorkerResult( + result: CodeModeWorkerResult, config: CodeModeConfig, - consumedBytes = 0, -): number { - const bytes = Buffer.byteLength(JSON.stringify(toJsonSafe(value)) ?? "null", "utf8"); - if (consumedBytes + bytes > config.maxOutputBytes) { - throw new CodeModeWorkerFailure("output_limit_exceeded", "code mode output limit exceeded"); +): CodeModeWorkerResult { + const bounded = boundCodeModeResult({ + output: result.output, + ...(result.status === "completed" ? { value: result.value } : {}), + 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["code"], + error: string, + output: unknown[] = [], +): Extract { + return { + status: "failed", + code, + error, + failurePhase: code === "invalid_input" ? "input" : "guest", + bridgeDispatchStarted: false, + output, + }; +} + +function workerFailureResult(params: { error: unknown; didTimeout: () => boolean; output: unknown[]; vm: QuickJS; - config: CodeModeConfig; -}): never { +}): CodeModeWorkerResult { const timedOut = params.didTimeout() || isQuickJsInterruptedError(params.error); - const failureOutput = 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; - } + const output = params.output.length > 0 ? params.output : takeOutputSafely(params.vm); if (timedOut) { - throw new CodeModeWorkerFailureWithOutput( - "timeout", - "code mode timeout exceeded", - failureOutput, - { cause: params.error }, - ); + return failedWorkerResult("timeout", "code mode timeout exceeded", output); } if (params.error instanceof CodeModeWorkerFailure) { - throw new CodeModeWorkerFailureWithOutput( - params.error.code, - params.error.message, - failureOutput, - { cause: params.error }, - ); + return failedWorkerResult(params.error.code, params.error.message, output); } - if (failureOutput.length > 0) { - throw new CodeModeWorkerFailureWithOutput( - "internal_error", - errorMessage(params.error), - failureOutput, - { cause: params.error }, - ); + if (output.length > 0) { + return failedWorkerResult("internal_error", errorMessage(params.error), output); } throw params.error; } @@ -356,7 +323,6 @@ async function runVmExecution(params: { params.prepare(); params.vm.executePendingJobs(); output = takeOutput(params.vm); - const outputBytes = enforceWorkerOutputLimit(output, params.config); const resultHandle = params.vm.global.getProp("__openclawResult"); try { const promisePending = resultHandle.isPromise && resultHandle.promiseState === 0; @@ -378,22 +344,16 @@ async function runVmExecution(params: { }); } 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 { resultHandle.dispose(); } } catch (error) { - return throwWorkerFailureWithOutput({ + return workerFailureResult({ error, didTimeout: params.didTimeout, output, vm: params.vm, - config: params.config, }); } finally { params.vm.dispose(); @@ -471,52 +431,47 @@ function isQuickJsWasmModule(value: unknown): value is WebAssembly.Module { async function main(): Promise { const input = workerData as unknown; if (!isRecord(input) || !isRecord(input.config) || !isQuickJsWasmModule(input.wasmModule)) { - return { - status: "failed", - error: "invalid code mode worker input", - code: "invalid_input", - failurePhase: "input", - bridgeDispatchStarted: false, - output: [], - }; + return failedWorkerResult("invalid_input", "invalid code mode worker input"); } + const config = input.config as CodeModeConfig; try { if (input.kind === "exec" && typeof input.source === "string") { - return await runExec({ - kind: "exec", - wasmModule: input.wasmModule, - source: input.source, - config: input.config as CodeModeConfig, - catalog: Array.isArray(input.catalog) ? input.catalog : [], - apiFiles: Array.isArray(input.apiFiles) ? (input.apiFiles as CodeModeApiVirtualFile[]) : [], - namespaces: Array.isArray(input.namespaces) - ? (input.namespaces as CodeModeNamespaceDescriptor[]) - : [], - swarmEnabled: input.swarmEnabled === true, - }); + return boundWorkerResult( + await runExec({ + kind: "exec", + wasmModule: input.wasmModule, + source: input.source, + config, + catalog: Array.isArray(input.catalog) ? input.catalog : [], + apiFiles: Array.isArray(input.apiFiles) + ? (input.apiFiles as CodeModeApiVirtualFile[]) + : [], + namespaces: Array.isArray(input.namespaces) + ? (input.namespaces as CodeModeNamespaceDescriptor[]) + : [], + swarmEnabled: input.swarmEnabled === true, + }), + config, + ); } if (input.kind === "resume" && input.snapshotBytes instanceof Uint8Array) { - return await runResume({ - kind: "resume", - wasmModule: input.wasmModule, - snapshotBytes: input.snapshotBytes, - config: input.config as CodeModeConfig, - settledRequests: Array.isArray(input.settledRequests) - ? (input.settledRequests as SettledBridgeRequest[]) - : [], - pendingRequests: Array.isArray(input.pendingRequests) - ? (input.pendingRequests as PendingBridgeRequest[]) - : [], - }); + return boundWorkerResult( + await runResume({ + kind: "resume", + wasmModule: input.wasmModule, + snapshotBytes: input.snapshotBytes, + config, + settledRequests: Array.isArray(input.settledRequests) + ? (input.settledRequests as SettledBridgeRequest[]) + : [], + pendingRequests: Array.isArray(input.pendingRequests) + ? (input.pendingRequests as PendingBridgeRequest[]) + : [], + }), + config, + ); } - return { - status: "failed", - error: "invalid code mode worker input", - code: "invalid_input", - failurePhase: "input", - bridgeDispatchStarted: false, - output: [], - }; + return failedWorkerResult("invalid_input", "invalid code mode worker input"); } catch (error) { const timedOut = isQuickJsInterruptedError(error); const code = timedOut @@ -524,14 +479,7 @@ async function main(): Promise { : error instanceof CodeModeWorkerFailure ? error.code : "internal_error"; - return { - 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 : [], - }; + return failedWorkerResult(code, timedOut ? "code mode timeout exceeded" : errorMessage(error)); } }