mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
6076efc968
commit
e6ca5266af
+16
-6
@@ -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.
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
expect(boundCodeModeResult({ output, value, maxOutputBytes })).toMatchObject({
|
||||
output,
|
||||
value,
|
||||
config: { ...config, maxOutputBytes: maxOutputBytes - 1 },
|
||||
}),
|
||||
).toThrow("code mode output limit exceeded");
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<CodeModeFailureCode, "output_limit_exceeded" | "snapshot_limit_exceeded">;
|
||||
readonly code = "snapshot_limit_exceeded" as const;
|
||||
|
||||
constructor(
|
||||
code: Extract<CodeModeFailureCode, "output_limit_exceeded" | "snapshot_limit_exceeded">,
|
||||
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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", {}] },
|
||||
});
|
||||
|
||||
@@ -210,18 +210,30 @@ 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 }) => {
|
||||
{
|
||||
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);
|
||||
@@ -236,14 +248,19 @@ describe("Code Mode worker lifecycle", () => {
|
||||
10_000,
|
||||
);
|
||||
|
||||
expect(result.status).toBe("failed");
|
||||
if (result.status !== "failed") {
|
||||
return;
|
||||
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");
|
||||
}
|
||||
expect(result.code).toBe("output_limit_exceeded");
|
||||
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 () => {
|
||||
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] });
|
||||
|
||||
@@ -92,7 +92,6 @@ export type CodeModeWorkerThreadResult =
|
||||
| "invalid_input"
|
||||
| "runtime_unavailable"
|
||||
| "timeout"
|
||||
| "output_limit_exceeded"
|
||||
| "snapshot_limit_exceeded"
|
||||
| "internal_error";
|
||||
failurePhase: Extract<CodeModeFailurePhase, "input" | "guest">;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
|
||||
+57
-109
@@ -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<CodeModeWorkerResult, { status: "failed" }>["code"];
|
||||
|
||||
constructor(
|
||||
code: Extract<CodeModeWorkerResult, { status: "failed" }>["code"],
|
||||
message: string,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(message, options);
|
||||
constructor(code: Extract<CodeModeWorkerResult, { status: "failed" }>["code"], message: string) {
|
||||
super(message);
|
||||
this.name = "CodeModeWorkerFailure";
|
||||
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 {
|
||||
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<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;
|
||||
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<CodeModeWorkerResult> {
|
||||
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({
|
||||
return boundWorkerResult(
|
||||
await runExec({
|
||||
kind: "exec",
|
||||
wasmModule: input.wasmModule,
|
||||
source: input.source,
|
||||
config: input.config as CodeModeConfig,
|
||||
config,
|
||||
catalog: Array.isArray(input.catalog) ? input.catalog : [],
|
||||
apiFiles: Array.isArray(input.apiFiles) ? (input.apiFiles as CodeModeApiVirtualFile[]) : [],
|
||||
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({
|
||||
return boundWorkerResult(
|
||||
await runResume({
|
||||
kind: "resume",
|
||||
wasmModule: input.wasmModule,
|
||||
snapshotBytes: input.snapshotBytes,
|
||||
config: input.config as CodeModeConfig,
|
||||
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<CodeModeWorkerResult> {
|
||||
: 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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user