Files
openclaw/src/agents/code-mode-state.ts
2026-07-28 20:58:24 -04:00

416 lines
14 KiB
TypeScript

import { randomUUID } from "node:crypto";
import {
isFutureDateTimestampMs,
resolveExpiresAtMsFromDurationSeconds,
} from "@openclaw/normalization-core/number-coercion";
import { runBridgeRequest } from "./code-mode-bridge.js";
import { CODE_MODE_EXEC_TOOL_NAME, CODE_MODE_WAIT_TOOL_NAME } from "./code-mode-control-tools.js";
import type { CodeModeNamespaceRuntime } from "./code-mode-namespaces.js";
import {
enforceSnapshotPayloadLimits,
type CodeModeConfig,
type CodeModeSettlementMode,
type PendingBridgeRequest,
type SettledBridgeRequest,
} from "./code-mode-runtime.js";
import type { AgentToolUpdateCallback } from "./runtime/index.js";
import { ToolSearchRuntime, type ToolSearchToolContext } from "./tool-search.js";
import { ToolInputError } from "./tools/common.js";
export type PendingBridgeState = PendingBridgeRequest & {
promise: Promise<SettledBridgeRequest>;
settled?: SettledBridgeRequest;
settledSequence?: number;
cancel?: () => void;
};
type CodeModeRunState = {
runId: string;
replayId: string;
parentToolCallId: string;
ctx: ToolSearchToolContext;
config: CodeModeConfig;
snapshotBytes: Uint8Array;
pending: PendingBridgeState[];
settlementMode: CodeModeSettlementMode;
// True only when every future bridge call is enforced read-only before execution.
replaySafe: boolean;
output: unknown[];
// Retain all output for cumulative limits, but never replay blocks already returned to the model.
deliveredOutputCount: number;
expiresAt: number;
agentWaitRetainUntil?: number;
runtime: ToolSearchRuntime;
namespaceRuntime: CodeModeNamespaceRuntime;
};
const MAX_ACTIVE_CODE_MODE_RUNS = 64;
const MAX_AGENT_WAIT_SNAPSHOT_TTL_WINDOWS = 4;
export const activeRuns = new Map<string, CodeModeRunState>();
export const resumingRunIds = new Set<string>();
let activeRunReservations = 0;
let nextPendingBridgeSettlementSequence = 0;
let activeRunExpiryTimer: ReturnType<typeof setTimeout> | undefined;
// One unreferenced timer owns parked snapshots even when no later exec or wait
// arrives; otherwise expired runs keep their VM bytes and live tool calls.
function scheduleActiveRunExpiry(): void {
if (activeRunExpiryTimer) {
clearTimeout(activeRunExpiryTimer);
activeRunExpiryTimer = undefined;
}
let nextExpiresAt = Number.POSITIVE_INFINITY;
for (const state of activeRuns.values()) {
nextExpiresAt = Math.min(nextExpiresAt, state.expiresAt);
}
if (!Number.isFinite(nextExpiresAt)) {
return;
}
activeRunExpiryTimer = setTimeout(
() => {
activeRunExpiryTimer = undefined;
removeExpiredRuns();
scheduleActiveRunExpiry();
},
Math.max(1, nextExpiresAt - Date.now()),
);
activeRunExpiryTimer.unref?.();
}
export function removeExpiredRuns(now = Date.now()): void {
for (const [runId, state] of activeRuns) {
if (!isFutureDateTimestampMs(state.expiresAt, { nowMs: now })) {
// Parked collectors extend idle TTL, bounded so a lost terminal event cannot pin all slots.
if (
state.pending?.some((entry) => entry.method === "agentWait" && !entry.settled) &&
state.agentWaitRetainUntil !== undefined &&
isFutureDateTimestampMs(state.agentWaitRetainUntil, { nowMs: now })
) {
const renewed = resolveCodeModeSnapshotExpiresAt(now, state.config.snapshotTtlSeconds);
if (renewed !== undefined) {
state.expiresAt = Math.min(renewed, state.agentWaitRetainUntil);
continue;
}
}
disposeCodeModeRun(runId);
}
}
}
export function disposeCodeModeRun(runId: string): void {
const state = activeRuns.get(runId);
cancelPendingBridgeStates(state?.pending ?? []);
activeRuns.delete(runId);
resumingRunIds.delete(runId);
scheduleActiveRunExpiry();
}
/** Cancel suspended bridge work before its Gateway-owned runtimes disappear. */
export function disposeAllCodeModeRuns(): void {
activeRuns.forEach((state) => cancelPendingBridgeStates(state.pending));
activeRuns.clear();
resumingRunIds.clear();
scheduleActiveRunExpiry();
}
/** Advance the snapshot frontier before exposing output to a wait observer. */
export function takeUndeliveredCodeModeRunOutput(state: CodeModeRunState): unknown[] {
const output = state.output.slice(state.deliveredOutputCount);
state.deliveredOutputCount = state.output.length;
return output;
}
/** Abort each bridge call whose result has not already reached its guest. */
export function cancelPendingBridgeStates(pending: readonly PendingBridgeState[]): void {
for (const entry of pending) {
if (!entry.settled) {
entry.cancel?.();
}
}
}
/** Deliver bridge responses in actual settlement order, not request order. */
export function settledBridgeRequestsInCompletionOrder(
pending: readonly PendingBridgeState[],
): SettledBridgeRequest[] {
return pending
.filter((entry) => entry.settled !== undefined)
.toSorted((left, right) => (left.settledSequence ?? 0) - (right.settledSequence ?? 0))
.flatMap((entry) => (entry.settled ? [entry.settled] : []));
}
/** Keep every dispatched bridge call required until its guest has received the result. */
export function pendingBridgeStatesForSettlement(
pending: readonly PendingBridgeState[],
settlementMode: CodeModeSettlementMode,
): readonly PendingBridgeState[] {
if (settlementMode.kind === "awaiting") {
return pending;
}
const requiredRequestIds = new Set(settlementMode.requiredRequestIds);
return pending.filter((entry) => requiredRequestIds.has(entry.id));
}
/** Await the shared guest frontier without guessing native Promise ownership. */
export function waitForPendingBridgeSettlement(
pending: readonly PendingBridgeState[],
settlementMode: CodeModeSettlementMode,
): Promise<void> {
const required = pendingBridgeStatesForSettlement(pending, settlementMode);
const outstanding = required.filter((entry) => !entry.settled);
// Workers reject hostless pending guests; headless execution also validates
// the frontier before reaching this shared settlement helper.
if (
outstanding.length === 0 ||
(settlementMode.kind === "awaiting" && outstanding.length !== required.length)
) {
return Promise.resolve();
}
const settlement =
settlementMode.kind === "draining"
? Promise.all(outstanding.map((entry) => entry.promise))
: Promise.race(outstanding.map((entry) => entry.promise));
return settlement.then(() => undefined);
}
function resolveCodeModeSnapshotExpiresAt(now: number, ttlSeconds: number): number | undefined {
return resolveExpiresAtMsFromDurationSeconds(ttlSeconds, { nowMs: now });
}
function enforceActiveRunLimit(): void {
removeExpiredRuns();
if (activeRuns.size + activeRunReservations >= MAX_ACTIVE_CODE_MODE_RUNS) {
throw new ToolInputError("too many suspended code mode runs.");
}
}
export function reserveActiveRunSlot(ownedRunId?: string): () => void {
if (ownedRunId === undefined) {
enforceActiveRunLimit();
} else if (!activeRuns.delete(ownedRunId)) {
throw new ToolInputError("code mode run is unavailable or expired.");
}
// Resume transfers an existing slot without exposing a free capacity window
// to concurrent exec calls or rejecting its own run at the global limit.
activeRunReservations += 1;
let released = false;
return () => {
if (released) {
return;
}
released = true;
activeRunReservations = Math.max(0, activeRunReservations - 1);
};
}
export function snapshotState(params: {
pendingRequests: PendingBridgeRequest[];
snapshotBytes: Uint8Array;
parentToolCallId: string;
codeModeReplayId: string;
ctx: ToolSearchToolContext;
config: CodeModeConfig;
runtime: ToolSearchRuntime;
namespaceRuntime: CodeModeNamespaceRuntime;
output: unknown[];
deliveredOutputCount?: number;
reservedActiveRunSlot?: boolean;
replaySafe: boolean;
settlementMode: CodeModeSettlementMode;
signal?: AbortSignal;
onUpdate?: AgentToolUpdateCallback;
}) {
enforceSnapshotStateLimits(params);
const runId = `cm_${randomUUID()}`;
const pending = createPendingBridgeStates({
...params,
activeRunId: runId,
codeModeRunId: params.codeModeReplayId,
});
try {
return storeSnapshotState({
...params,
runId,
replayId: params.codeModeReplayId,
pending,
replaySafe:
params.replaySafe &&
pendingBridgeRequestsReplaySafe(params.pendingRequests, params.runtime),
});
} catch (error) {
cancelPendingBridgeStates(pending);
throw error;
}
}
export function pendingBridgeRequestsReplaySafe(
pending: readonly PendingBridgeRequest[],
runtime: ToolSearchRuntime,
): boolean {
return pending.every((request) => {
if (
request.method === "search" ||
request.method === "describe" ||
request.method === "yield" ||
request.method === "agentSpawn" ||
request.method === "agentWait" ||
request.method === "skillsList" ||
request.method === "skillsRead"
) {
return true;
}
if (request.method !== "call" && request.method !== "callValue") {
return false;
}
const id = Array.isArray(request.args) ? request.args[0] : undefined;
return typeof id === "string" && runtime.isReplaySafeExactId(id);
});
}
function enforceSnapshotStateLimits(params: {
snapshotBytes: Uint8Array;
config: CodeModeConfig;
output: unknown[];
reservedActiveRunSlot?: boolean;
}) {
if (!params.reservedActiveRunSlot) {
enforceActiveRunLimit();
}
enforceSnapshotPayloadLimits(params);
}
export function createPendingBridgeStates(params: {
pendingRequests: PendingBridgeRequest[];
runtime: ToolSearchRuntime;
namespaceRuntime: CodeModeNamespaceRuntime;
parentToolCallId: string;
codeModeRunId: string;
activeRunId?: string;
ctx: ToolSearchToolContext;
signal?: AbortSignal;
onUpdate?: AgentToolUpdateCallback;
}): PendingBridgeState[] {
return params.pendingRequests.map((request) => {
// Bridge calls start immediately while the VM snapshot is stored. Their
// settled values are later replayed into QuickJS by the wait tool.
const abortController = new AbortController();
const signal = params.signal
? AbortSignal.any([params.signal, abortController.signal])
: abortController.signal;
const state: PendingBridgeState = {
...request,
promise: runBridgeRequest({
runtime: params.runtime,
namespaceRuntime: params.namespaceRuntime,
parentToolCallId: params.parentToolCallId,
codeModeRunId: params.codeModeRunId,
ctx: params.ctx,
request,
signal,
onUpdate: params.onUpdate,
}).then((settled) => {
state.settledSequence = ++nextPendingBridgeSettlementSequence;
state.settled = settled;
if (state.method === "agentWait" && params.activeRunId) {
const active = activeRuns.get(params.activeRunId);
if (active?.pending.includes(state)) {
const renewed = resolveCodeModeSnapshotExpiresAt(
Date.now(),
active.config.snapshotTtlSeconds,
);
if (renewed !== undefined) {
active.expiresAt = renewed;
scheduleActiveRunExpiry();
}
}
}
return settled;
}),
cancel: () => abortController.abort(),
};
return state;
});
}
export function storeSnapshotState(params: {
runId: string;
replayId: string;
pending: PendingBridgeState[];
replaySafe: boolean;
settlementMode: CodeModeSettlementMode;
snapshotBytes: Uint8Array;
parentToolCallId: string;
ctx: ToolSearchToolContext;
config: CodeModeConfig;
runtime: ToolSearchRuntime;
namespaceRuntime: CodeModeNamespaceRuntime;
output: unknown[];
deliveredOutputCount?: number;
}) {
const now = Date.now();
const expiresAt = resolveCodeModeSnapshotExpiresAt(now, params.config.snapshotTtlSeconds);
if (expiresAt === undefined) {
throw new ToolInputError("code mode run expiry is unavailable.");
}
const hasPendingAgentWait = params.pending.some(
(entry) => entry.method === "agentWait" && !entry.settled,
);
const agentWaitRetainUntil = hasPendingAgentWait
? resolveCodeModeSnapshotExpiresAt(
now,
params.config.snapshotTtlSeconds * MAX_AGENT_WAIT_SNAPSHOT_TTL_WINDOWS,
)
: undefined;
activeRuns.set(params.runId, {
runId: params.runId,
replayId: params.replayId,
parentToolCallId: params.parentToolCallId,
ctx: params.ctx,
config: params.config,
snapshotBytes: params.snapshotBytes,
pending: params.pending,
settlementMode: params.settlementMode,
replaySafe: params.replaySafe,
output: params.output,
deliveredOutputCount: params.output.length,
expiresAt,
agentWaitRetainUntil,
runtime: params.runtime,
namespaceRuntime: params.namespaceRuntime,
});
scheduleActiveRunExpiry();
return {
status: "waiting" as const,
runId: params.runId,
reason: codeModeWaitingReason(params.pending),
pendingToolCalls: pendingToolCalls(params.pending),
replaySafe: params.replaySafe,
output: params.output.slice(params.deliveredOutputCount ?? 0),
telemetry: telemetry(params.runtime),
};
}
export function codeModeWaitingReason(
pending: readonly PendingBridgeState[],
): "pending_tools" | "yield" {
return pending.length > 0 && pending.every((entry) => entry.method === "yield")
? "yield"
: "pending_tools";
}
export function pendingToolCalls(pending: readonly PendingBridgeState[]) {
// Settled calls remain in snapshots until QuickJS consumes their response,
// but they must not be advertised as outstanding work to exec or wait.
return pending
.filter((entry) => !entry.settled)
.map((entry) => ({ id: entry.id, method: entry.method }));
}
export function telemetry(runtime: ToolSearchRuntime) {
return {
...runtime.telemetry(),
visibleTools: [CODE_MODE_EXEC_TOOL_NAME, CODE_MODE_WAIT_TOOL_NAME],
};
}