fix(agents): bound Code Mode repair retries (#115729)

This commit is contained in:
Vincent Koc
2026-07-29 15:26:00 +08:00
committed by GitHub
parent e31dc59250
commit 892907bf25
21 changed files with 1165 additions and 42 deletions
+1
View File
@@ -52,6 +52,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- **Control UI session diffs:** hide unchanged checkout modifications and untracked files that already existed when a thread started, so the diff panel attributes only files touched by that session. Fixes #115628.
- **Code Mode small-model repair:** give malformed pre-dispatch `exec` calls one bounded correction turn, expose typed failure-phase and bridge-dispatch evidence, and stop retries after nested tools begin. Fixes #115311.
- **Shared state corruption recovery:** evict only the exact cached SQLite owner after proven read or write corruption so a repaired database recovers without a Gateway restart while caller-injected handles remain untouched. Fixes #114269. Thanks @rizquuula.
- **Dev-channel updates:** finish package-to-git switches in a fresh CLI process even when source SHA and version metadata are unchanged, preventing stale hashed chunks from loading after the global package root changes.
- **Parallels release smoke:** preserve Windows installer reboot results across Parallels, wait for WSL MSI/default-version readiness, force explicit test-owned gateway stops, and reset Linux package, config, and cache state before install lanes, preventing false prerequisite, safety-gate, and stale-config failures.
+80 -2
View File
@@ -1359,6 +1359,9 @@ describe("agentLoop tool termination", () => {
it("marks argument validation failures with typed provenance", async () => {
const executed: string[] = [];
const afterToolOutcome = vi.fn(async () => ({
details: { observed: "pre-execution" },
}));
let turn = 0;
const streamFn: StreamFn = () => {
turn += 1;
@@ -1388,7 +1391,7 @@ describe("agentLoop tool termination", () => {
agentLoop(
[{ role: "user", content: "hello", timestamp: 1 }],
{ systemPrompt: "", messages: [], tools: [tool] },
config,
{ ...config, afterToolOutcome },
undefined,
streamFn,
),
@@ -1402,10 +1405,78 @@ describe("agentLoop tool termination", () => {
expect(endEvent).toMatchObject({
executionStarted: false,
errorKind: "argument-validation",
result: {
details: { observed: "pre-execution" },
},
});
expect(afterToolOutcome).toHaveBeenCalledWith(
expect.objectContaining({
args: {},
executionStarted: false,
errorKind: "argument-validation",
isError: true,
toolCall: expect.objectContaining({ name: "edit" }),
}),
undefined,
);
});
it("stops after a tool result only when the finalized result explicitly terminates", async () => {
it("runs the finalized-outcome hook after the executed-only hook", async () => {
const executed: string[] = [];
const order: string[] = [];
let turn = 0;
const streamFn: StreamFn = () => {
turn += 1;
const stream = createAssistantMessageEventStream();
queueMicrotask(() => {
const message =
turn === 1
? makeAssistantMessage([
{ type: "toolCall", id: "call-read", name: "read", arguments: {} },
])
: makeAssistantMessage([{ type: "text", text: "done" }]);
stream.push({
type: "done",
reason: message.stopReason === "toolUse" ? "toolUse" : "stop",
message,
});
stream.end();
});
return stream;
};
const events = await collectEvents(
agentLoop(
[{ role: "user", content: "hello", timestamp: 1 }],
{ systemPrompt: "", messages: [], tools: [makeTool("read", executed)] },
{
...config,
afterToolCall: async () => {
order.push("afterToolCall");
return { details: { phase: "executed" } };
},
afterToolOutcome: async ({ result, executionStarted }) => {
order.push("afterToolOutcome");
expect(result.details).toEqual({ phase: "executed" });
expect(executionStarted).toBe(true);
return { details: { phase: "finalized" } };
},
},
undefined,
streamFn,
),
);
const endEvent = events.find(
(event): event is Extract<AgentEvent, { type: "tool_execution_end" }> =>
event.type === "tool_execution_end",
);
expect(executed).toEqual(["read"]);
expect(order).toEqual(["afterToolCall", "afterToolOutcome"]);
expect(endEvent?.result).toMatchObject({ details: { phase: "finalized" } });
});
it("preserves a terminal result when the finalized-outcome hook throws", async () => {
const executed: string[] = [];
let turn = 0;
const streamFn: StreamFn = () => {
@@ -1437,6 +1508,9 @@ describe("agentLoop tool termination", () => {
...config,
afterToolCall: async ({ toolCall }) =>
toolCall.name === "message" ? { terminate: true } : undefined,
afterToolOutcome: async () => {
throw new Error("finalized hook failed");
},
},
undefined,
streamFn,
@@ -1447,6 +1521,10 @@ describe("agentLoop tool termination", () => {
expect(turn).toBe(1);
expect(executed).toEqual(["message"]);
expect(events.filter((event) => event.type === "tool_execution_start")).toHaveLength(1);
expect(events.find((event) => event.type === "tool_execution_end")?.result).toMatchObject({
content: [{ type: "text", text: "finalized hook failed" }],
terminate: true,
});
expect(events.at(-1)).toMatchObject({ type: "agent_end" });
});
+99 -23
View File
@@ -669,14 +669,21 @@ async function executeToolCallsSequential(
);
let finalized: FinalizedToolCallOutcome;
if (preparation.kind === "immediate") {
finalized = {
toolCall,
result: preparation.result,
isError: preparation.isError,
executionStarted: false,
...(preparation.errorKind ? { errorKind: preparation.errorKind } : {}),
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
};
finalized = await finalizeToolCallOutcome(
currentContext,
assistantMessage,
{
toolCall,
result: preparation.result,
isError: preparation.isError,
executionStarted: false,
...(preparation.errorKind ? { errorKind: preparation.errorKind } : {}),
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
},
toolCall.arguments,
config,
signal,
);
} else {
const executed = await executePreparedToolCall(
preparation,
@@ -745,14 +752,21 @@ async function executeToolCallsParallel(
resolvedToolCalls,
);
if (preparation.kind === "immediate") {
const finalized = {
toolCall,
result: preparation.result,
isError: preparation.isError,
executionStarted: false,
...(preparation.errorKind ? { errorKind: preparation.errorKind } : {}),
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
} satisfies FinalizedToolCallOutcome;
const finalized = await finalizeToolCallOutcome(
currentContext,
assistantMessage,
{
toolCall,
result: preparation.result,
isError: preparation.isError,
executionStarted: false,
...(preparation.errorKind ? { errorKind: preparation.errorKind } : {}),
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
},
toolCall.arguments,
config,
signal,
);
await emitToolExecutionEnd(finalized, emit);
finalizedCalls.push(finalized);
if (signal?.aborted) {
@@ -1106,13 +1120,75 @@ async function finalizeExecutedToolCall(
}
}
return {
toolCall: prepared.toolCall,
result,
isError,
executionStarted: executed.executionStarted,
...(prepared.tool.hideFromChannelProgress === true ? { hideFromChannelProgress: true } : {}),
};
return await finalizeToolCallOutcome(
currentContext,
assistantMessage,
{
toolCall: prepared.toolCall,
result,
isError,
executionStarted: executed.executionStarted,
...(prepared.tool.hideFromChannelProgress === true ? { hideFromChannelProgress: true } : {}),
},
prepared.args,
config,
signal,
);
}
async function finalizeToolCallOutcome(
currentContext: AgentContext,
assistantMessage: AssistantMessage,
finalized: FinalizedToolCallOutcome,
args: unknown,
config: AgentLoopConfig,
signal: AbortSignal | undefined,
): Promise<FinalizedToolCallOutcome> {
if (!config.afterToolOutcome) {
return finalized;
}
try {
const afterResult = await config.afterToolOutcome(
{
assistantMessage,
toolCall: finalized.toolCall,
args,
result: finalized.result,
isError: finalized.isError,
executionStarted: finalized.executionStarted,
...(finalized.errorKind ? { errorKind: finalized.errorKind } : {}),
context: currentContext,
},
signal,
);
if (!afterResult) {
return finalized;
}
return {
...finalized,
result: {
...finalized.result,
content: afterResult.content ?? finalized.result.content,
details: afterResult.details ?? finalized.result.details,
terminate: afterResult.terminate ?? finalized.result.terminate,
},
isError: afterResult.isError ?? finalized.isError,
};
} catch (error) {
const errorResult = createErrorToolResult(
error instanceof Error ? error.message : String(error),
);
return {
...finalized,
result: {
...errorResult,
...(finalized.result.terminate === undefined
? {}
: { terminate: finalized.result.terminate }),
},
isError: true,
};
}
}
function createErrorToolResult(message: string): AgentToolResult<unknown> {
+12
View File
@@ -20,6 +20,7 @@ import {
import type {
AfterToolCallContext,
AfterToolCallResult,
AfterToolOutcomeContext,
AgentContext,
AgentEvent,
AgentLoopConfig,
@@ -130,6 +131,11 @@ export interface AgentOptions {
context: AfterToolCallContext,
signal?: AbortSignal,
) => Promise<AfterToolCallResult | undefined>;
/** Hook that may alter any finalized tool outcome, including pre-execution failures. */
afterToolOutcome?: (
context: AfterToolOutcomeContext,
signal?: AbortSignal,
) => Promise<AfterToolCallResult | undefined>;
/** Hook that may update model, reasoning, or context after a turn. */
prepareNextTurn?: (
signal?: AbortSignal,
@@ -231,6 +237,10 @@ export class Agent {
context: AfterToolCallContext,
signal?: AbortSignal,
) => Promise<AfterToolCallResult | undefined>;
public afterToolOutcome?: (
context: AfterToolOutcomeContext,
signal?: AbortSignal,
) => Promise<AfterToolCallResult | undefined>;
public prepareNextTurn?: (
signal?: AbortSignal,
) => Promise<AgentLoopTurnUpdate | undefined> | AgentLoopTurnUpdate | undefined;
@@ -262,6 +272,7 @@ export class Agent {
this.beforeToolCall = options.beforeToolCall;
this.resolveDeferredTool = options.resolveDeferredTool;
this.afterToolCall = options.afterToolCall;
this.afterToolOutcome = options.afterToolOutcome;
this.prepareNextTurn = options.prepareNextTurn;
this.prepareNextTurnWithContext = options.prepareNextTurnWithContext;
this.steeringQueue = new PendingMessageQueue(options.steeringMode ?? "one-at-a-time");
@@ -500,6 +511,7 @@ export class Agent {
beforeToolCall: this.beforeToolCall,
resolveDeferredTool: this.resolveDeferredTool,
afterToolCall: this.afterToolCall,
afterToolOutcome: this.afterToolOutcome,
prepareNextTurn:
this.prepareNextTurnWithContext || this.prepareNextTurn
? async (context) => {
+35
View File
@@ -116,6 +116,32 @@ export interface AfterToolCallContext {
context: AgentContext;
}
/**
* Context passed to `afterToolOutcome` after every finalized tool outcome.
*
* Unlike `afterToolCall`, this hook also observes failures that prevented
* execution. `args` contains validated arguments when execution reached the
* prepared state, otherwise the raw model arguments.
*/
export interface AfterToolOutcomeContext {
/** The assistant message that requested the tool call. */
assistantMessage: AssistantMessage;
/** The tool call whose final result is being emitted. */
toolCall: AgentToolCall;
/** Validated arguments when available, otherwise the raw model arguments. */
args: unknown;
/** Final result after any executed-only `afterToolCall` override. */
result: AgentToolResult<unknown>;
/** Whether the finalized result is currently treated as an error. */
isError: boolean;
/** Whether the tool implementation started executing. */
executionStarted: boolean;
/** Typed pre-execution failure provenance when available. */
errorKind?: "argument-validation";
/** Current agent context at the time the tool outcome is finalized. */
context: AgentContext;
}
/** Context passed to `shouldStopAfterTurn`. */
export interface ShouldStopAfterTurnContext {
/** The assistant message that completed the turn. */
@@ -301,6 +327,15 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
context: AfterToolCallContext,
signal?: AbortSignal,
) => Promise<AfterToolCallResult | undefined>;
/**
* Called after every tool outcome is finalized, including failures that
* prevented execution. It runs after `afterToolCall` for executed tools.
*/
afterToolOutcome?: (
context: AfterToolOutcomeContext,
signal?: AbortSignal,
) => Promise<AfterToolCallResult | undefined>;
}
/**
+49 -5
View File
@@ -69,11 +69,14 @@ export async function runExec(params: {
throw new ToolInputError("code mode is disabled.");
}
const runtime = new ToolSearchRuntime(params.ctx, toToolSearchConfig(config));
const bridgeDispatch = { started: false };
if (params.signal?.aborted) {
return {
status: "failed" as const,
error: "code mode execution aborted",
code: "aborted" as const,
failurePhase: "host" as const,
bridgeDispatchStarted: false,
output: [],
replaySafe: params.restartSafe,
telemetry: telemetry(runtime),
@@ -133,14 +136,22 @@ export async function runExec(params: {
config,
runtime,
namespaceRuntime,
bridgeDispatch,
signal: params.signal,
onUpdate: params.onUpdate,
});
} catch (error) {
const code = params.signal?.aborted ? ("aborted" as const) : codeModeFailureCode(error);
return {
status: "failed" as const,
error: params.signal?.aborted ? "code mode execution aborted" : codeModeFailureMessage(error),
code: params.signal?.aborted ? ("aborted" as const) : codeModeFailureCode(error),
code,
failurePhase: bridgeDispatch.started
? ("bridge" as const)
: code === "invalid_input"
? ("input" as const)
: ("host" as const),
bridgeDispatchStarted: bridgeDispatch.started,
output: [],
replaySafe: params.restartSafe,
telemetry: telemetry(runtime),
@@ -219,6 +230,7 @@ async function settleCodeModeResult(params: {
pending?: PendingBridgeState[];
activeRunId?: string;
reservedActiveRunSlot?: boolean;
bridgeDispatch: { started: boolean };
signal?: AbortSignal;
onUpdate?: AgentToolUpdateCallback;
}) {
@@ -238,6 +250,8 @@ async function settleCodeModeResult(params: {
status: "failed" as const,
error: "code mode execution aborted",
code: "aborted" as const,
failurePhase: params.bridgeDispatch.started ? ("bridge" as const) : ("host" as const),
bridgeDispatchStarted: params.bridgeDispatch.started,
output: output.slice(deliveredOutputCount),
replaySafe: params.replaySafe,
telemetry: telemetry(params.runtime),
@@ -262,6 +276,8 @@ async function settleCodeModeResult(params: {
status: "failed" as const,
error: "restart-safe code mode cannot call namespace tools.",
code: "invalid_input" as const,
failurePhase: params.bridgeDispatch.started ? ("bridge" as const) : ("input" as const),
bridgeDispatchStarted: params.bridgeDispatch.started,
output: output.slice(deliveredOutputCount),
replaySafe: true,
telemetry: telemetry(params.runtime),
@@ -288,9 +304,17 @@ async function settleCodeModeResult(params: {
releaseReservation = reserveActiveRunSlot();
}
const pendingIds = new Set(pending.map((entry) => entry.id));
const newPendingRequests = result.pendingRequests.filter(
(request) => !pendingIds.has(request.id),
);
if (newPendingRequests.length > 0) {
// createPendingBridgeStates starts host calls synchronously. Flip the
// evidence first so every later failure is permanently non-retryable.
params.bridgeDispatch.started = true;
}
pending.push(
...createPendingBridgeStates({
pendingRequests: result.pendingRequests.filter((request) => !pendingIds.has(request.id)),
pendingRequests: newPendingRequests,
runtime: params.runtime,
namespaceRuntime: params.namespaceRuntime,
parentToolCallId: params.parentToolCallId,
@@ -385,6 +409,8 @@ async function settleCodeModeResult(params: {
status: "failed" as const,
error: "restart-safe code mode cannot call side-effecting tools.",
code: "invalid_input" as const,
failurePhase: params.bridgeDispatch.started ? ("bridge" as const) : ("input" as const),
bridgeDispatchStarted: params.bridgeDispatch.started,
output: output.slice(deliveredOutputCount),
replaySafe: true,
telemetry: telemetry(params.runtime),
@@ -406,11 +432,15 @@ async function settleCodeModeResult(params: {
releaseReservation = reserveActiveRunSlot();
}
const pendingIds = new Set(pending.map((entry) => entry.id));
const newPendingRequests = result.pendingRequests.filter(
(request) => !pendingIds.has(request.id),
);
if (newPendingRequests.length > 0) {
params.bridgeDispatch.started = true;
}
pending.push(
...createPendingBridgeStates({
pendingRequests: result.pendingRequests.filter(
(request) => !pendingIds.has(request.id),
),
pendingRequests: newPendingRequests,
runtime: params.runtime,
namespaceRuntime: params.namespaceRuntime,
parentToolCallId: params.parentToolCallId,
@@ -443,6 +473,9 @@ async function settleCodeModeResult(params: {
releaseReservation?.();
}
}
if (result.pendingRequests.length > 0) {
params.bridgeDispatch.started = true;
}
return snapshotState({
pendingRequests: result.pendingRequests,
snapshotBytes: result.snapshotBytes,
@@ -471,6 +504,12 @@ async function settleCodeModeResult(params: {
});
return {
...result,
...(result.status === "failed"
? {
failurePhase: params.bridgeDispatch.started ? ("bridge" as const) : result.failurePhase,
bridgeDispatchStarted: params.bridgeDispatch.started,
}
: {}),
output: output.slice(deliveredOutputCount),
replaySafe: params.replaySafe,
telemetry: telemetry(params.runtime),
@@ -524,6 +563,8 @@ export async function runWait(params: {
status: "failed" as const,
error: "code mode execution aborted",
code: "aborted" as const,
failurePhase: "bridge" as const,
bridgeDispatchStarted: true,
output: takeUndeliveredCodeModeRunOutput(state),
replaySafe: state.replaySafe,
telemetry: telemetry(state.runtime),
@@ -583,6 +624,7 @@ export async function runWait(params: {
config: state.config,
runtime: state.runtime,
namespaceRuntime: state.namespaceRuntime,
bridgeDispatch: { started: true },
deliveredOutputCount: state.deliveredOutputCount,
pending,
activeRunId: state.runId,
@@ -600,6 +642,8 @@ export async function runWait(params: {
status: "failed" as const,
error: codeModeFailureMessage(error),
code: codeModeFailureCode(error),
failurePhase: "bridge" as const,
bridgeDispatchStarted: true,
output: takeUndeliveredCodeModeRunOutput(state),
replaySafe: state.replaySafe,
telemetry: telemetry(state.runtime),
+5 -2
View File
@@ -11,7 +11,7 @@ import {
CODE_MODE_SHELL_SOURCE_ERROR,
isShellLikeCodeModeSource,
} from "./code-mode-shell-source.js";
import type { CodeModeWorkerResult as WorkerThreadCodeModeResult } from "./code-mode-worker-types.js";
import type { CodeModeFailurePhase, CodeModeWorkerThreadResult } from "./code-mode-worker-types.js";
import type { ToolSearchConfig, ToolSearchToolContext } from "./tool-search.js";
import { asToolParamsRecord, ToolInputError } from "./tools/common.js";
@@ -51,6 +51,7 @@ export type CodeModeConfig = {
};
export type {
CodeModeFailurePhase,
CodeModeSettlementMode,
PendingBridgeRequest,
SettledBridgeRequest,
@@ -81,11 +82,13 @@ export type CodeModeHeadlessResult =
};
export type CodeModeWorkerResult =
| Extract<WorkerThreadCodeModeResult, { status: "completed" | "waiting" }>
| Extract<CodeModeWorkerThreadResult, { status: "completed" | "waiting" }>
| {
status: "failed";
error: string;
code: CodeModeFailureCode;
failurePhase: CodeModeFailurePhase;
bridgeDispatchStarted: boolean;
output: unknown[];
};
+5 -1
View File
@@ -70,7 +70,9 @@ export type CodeModeSettlementMode =
| { kind: "awaiting" }
| { kind: "draining"; requiredRequestIds: string[] };
export type CodeModeWorkerResult =
export type CodeModeFailurePhase = "input" | "guest" | "bridge" | "host";
export type CodeModeWorkerThreadResult =
| {
status: "completed";
value: unknown;
@@ -93,5 +95,7 @@ export type CodeModeWorkerResult =
| "output_limit_exceeded"
| "snapshot_limit_exceeded"
| "internal_error";
failurePhase: Extract<CodeModeFailurePhase, "input" | "guest">;
bridgeDispatchStarted: false;
output: unknown[];
};
+8
View File
@@ -50,6 +50,8 @@ function failedCodeModeWorkerResult(
status: "failed",
error: errorMessage(error),
code,
failurePhase: "host",
bridgeDispatchStarted: false,
output: [],
};
}
@@ -102,6 +104,8 @@ export async function runCodeModeWorker(
status: "failed",
error: "code mode worker timeout exceeded",
code: "timeout",
failurePhase: "host",
bridgeDispatchStarted: false,
output: [],
});
}, timeoutMs);
@@ -114,6 +118,8 @@ export async function runCodeModeWorker(
? "code mode timeout exceeded"
: "code mode execution aborted",
code: abortReason instanceof CodeModeHeadlessTimeoutError ? "timeout" : "aborted",
failurePhase: "host",
bridgeDispatchStarted: false,
output: [],
});
};
@@ -147,6 +153,8 @@ export async function runCodeModeWorker(
status: "failed",
error: "invalid code mode worker response",
code: "internal_error",
failurePhase: "host",
bridgeDispatchStarted: false,
output: [],
} satisfies CodeModeWorkerResult);
finish(normalizeCodeModeWorkerResult(result));
+34
View File
@@ -484,6 +484,40 @@ describe("Code Mode bridge settlement and cancellation", () => {
expect(testing.activeRuns.size).toBe(0);
});
it("marks failures after nested tool dispatch as non-retryable bridge failures", async () => {
const { config, catalogRef, tools: codeModeTools } = createCodeModeHarness();
const sideEffect = pluginToolWithExecute("fake_side_effect", "Side effect", async () =>
jsonResult({ ok: true }),
);
applyCodeModeCatalog({
tools: [...codeModeTools, sideEffect],
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-post-dispatch-failure",
{
code: `
await tools.callValue("fake_side_effect", {});
throw new Error("after dispatch");
`,
},
),
);
expect(sideEffect.execute).toHaveBeenCalledOnce();
expect(details).toMatchObject({
status: "failed",
failurePhase: "bridge",
bridgeDispatchStarted: true,
});
});
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.
+6
View File
@@ -234,6 +234,8 @@ describe("Code Mode runtime and output limits", () => {
expect(details.status).toBe("failed");
expect(String(details.error)).toContain("Error: boom");
expect(details.output).toEqual([{ type: "text", text: "before" }]);
expect(details.failurePhase).toBe("guest");
expect(details.bridgeDispatchStarted).toBe(false);
});
it("classifies snapshot limit failures", async () => {
@@ -305,6 +307,8 @@ describe("Code Mode runtime and output limits", () => {
status: "failed",
code: "timeout",
error: "interrupted",
failurePhase: "guest",
bridgeDispatchStarted: false,
output: [],
}),
).toMatchObject({
@@ -317,6 +321,8 @@ describe("Code Mode runtime and output limits", () => {
status: "failed",
code: "internal_error",
error: "interrupted",
failurePhase: "guest",
bridgeDispatchStarted: false,
output: [],
}),
).toMatchObject({
+41
View File
@@ -181,6 +181,47 @@ describe("Code Mode restart-safe replay", () => {
expect(targetTool.execute).not.toHaveBeenCalled();
});
it("preserves bridge evidence when a later restart-safe call is rejected", async () => {
const readTool = pluginTool("fake_safe_read", "Read");
setPluginToolMeta(readTool, {
pluginId: "fake-code-mode",
optional: true,
replaySafe: true,
});
const writeTool = pluginTool("fake_unsafe_write", "Write");
const { config, catalogRef, tools: codeModeTools } = createCodeModeHarness();
applyCodeModeCatalog({
tools: [...codeModeTools, readTool, writeTool],
config,
sessionId: "session-code-mode",
sessionKey: "agent:main:main",
runId: "run-code-mode",
catalogRef,
});
const failed = await runUntilCompleted({
execTool: expectDefined(codeModeTools[0], "codeModeTools[0] test invariant"),
waitTool: expectDefined(codeModeTools[1], "codeModeTools[1] test invariant"),
restartSafe: true,
code: `
const reads = await tools.search("fake_safe_read");
await tools.call(reads[0].id, {});
const writes = await tools.search("fake_unsafe_write");
return await tools.call(writes[0].id, {});
`,
});
expect(failed).toMatchObject({
status: "failed",
failurePhase: "bridge",
bridgeDispatchStarted: true,
replaySafe: true,
});
expect(failed.error).toContain("cannot call side-effecting tools");
expect(readTool.execute).toHaveBeenCalledTimes(1);
expect(writeTool.execute).not.toHaveBeenCalled();
});
it("keeps host-forced restart safety when the model clears the exec flag", async () => {
const targetTool = pluginTool("fake_forced_write", "Write");
const {
+8 -1
View File
@@ -41,7 +41,14 @@ type CodeModeWorkerResult =
pendingRequests: Array<{ id: string; method: string; args: unknown[] }>;
output: unknown[];
}
| { status: "failed"; error: string; code: CodeModeFailureCode; output: unknown[] };
| {
status: "failed";
error: string;
code: CodeModeFailureCode;
failurePhase: "input" | "guest" | "bridge" | "host";
bridgeDispatchStarted: boolean;
output: unknown[];
};
type CodeModeTestApi = {
activeRuns: Map<
+4
View File
@@ -144,6 +144,8 @@ describe("Code Mode TypeScript execution", () => {
status: "failed",
code: "timeout",
error: "code mode timeout exceeded",
failurePhase: "host",
bridgeDispatchStarted: false,
output: [],
});
expect(testing.activeRuns.size).toBe(0);
@@ -173,6 +175,8 @@ describe("Code Mode TypeScript execution", () => {
status: "failed",
code: "aborted",
error: "code mode execution aborted",
failurePhase: "host",
bridgeDispatchStarted: false,
output: [],
});
expect(testing.activeRuns.size).toBe(0);
+13 -6
View File
@@ -11,7 +11,7 @@ import type {
CodeModeConfig,
CodeModeNamespaceDescriptor,
CodeModeWorkerPayload,
CodeModeWorkerResult,
CodeModeWorkerThreadResult as CodeModeWorkerResult,
PendingBridgeRequest,
SettledBridgeRequest,
} from "./code-mode-worker-types.js";
@@ -482,6 +482,8 @@ async function main(): Promise<CodeModeWorkerResult> {
status: "failed",
error: "invalid code mode worker input",
code: "invalid_input",
failurePhase: "input",
bridgeDispatchStarted: false,
output: [],
};
}
@@ -518,18 +520,23 @@ async function main(): Promise<CodeModeWorkerResult> {
status: "failed",
error: "invalid code mode worker input",
code: "invalid_input",
failurePhase: "input",
bridgeDispatchStarted: false,
output: [],
};
} catch (error) {
const timedOut = isQuickJsInterruptedError(error);
const code = timedOut
? "timeout"
: error instanceof CodeModeWorkerFailure
? error.code
: "internal_error";
return {
status: "failed",
error: timedOut ? "code mode timeout exceeded" : errorMessage(error),
code: timedOut
? "timeout"
: error instanceof CodeModeWorkerFailure
? error.code
: "internal_error",
code,
failurePhase: code === "invalid_input" ? "input" : "guest",
bridgeDispatchStarted: false,
output: error instanceof CodeModeWorkerFailureWithOutput ? error.output : [],
};
}
@@ -11,6 +11,7 @@ const hoisted = vi.hoisted(() => ({
createEmbeddedAgentResourceLoader: vi.fn(),
createPreparedEmbeddedAgentSettingsManager: vi.fn(),
getGlobalHookRunner: vi.fn(),
installCodeModeRepairHook: vi.fn(),
installMessageToolOnlyTerminalHook: vi.fn(),
prepareEmbeddedAttemptClientTools: vi.fn(),
resolveEffectiveCompactionMode: vi.fn(),
@@ -58,6 +59,9 @@ vi.mock("../system-prompt.js", () => ({
vi.mock("./attempt-client-tools.js", () => ({
prepareEmbeddedAttemptClientTools: hoisted.prepareEmbeddedAttemptClientTools,
}));
vi.mock("./code-mode-repair.js", () => ({
installCodeModeRepairHook: hoisted.installCodeModeRepairHook,
}));
vi.mock("./message-tool-terminal.js", () => ({
installMessageToolOnlyTerminalHook: hoisted.installMessageToolOnlyTerminalHook,
}));
@@ -83,7 +87,10 @@ const attempt = {
workspaceDir: "/workspace",
} as unknown as EmbeddedRunAttemptParams;
function createInput(options?: { activationError?: Error }) {
function createInput(options?: {
activationError?: Error;
codeModeControlsEnabledForRun?: boolean;
}) {
const events: string[] = [];
const settingsManager = { id: "settings" };
const resourceLoader = {
@@ -142,6 +149,9 @@ function createInput(options?: { activationError?: Error }) {
onDeliveredSourceReply = input.onDeliveredSourceReply;
},
);
hoisted.installCodeModeRepairHook.mockImplementation(() => {
events.push("install-code-mode-repair");
});
return {
activeSession,
@@ -153,7 +163,10 @@ function createInput(options?: { activationError?: Error }) {
attempt,
agentCoreThinkingLevel: "high" as const,
agentDir: "/agent",
clientToolPreparation: { deferredDirectoryToolsCallable: false } as never,
clientToolPreparation: {
codeModeControlsEnabledForRun: options?.codeModeControlsEnabledForRun ?? true,
deferredDirectoryToolsCallable: false,
} as never,
effectiveCwd: "/workspace",
getCurrentAttemptPluginMetadataSnapshot: () => undefined,
initialSystemPrompt: "system prompt",
@@ -198,6 +211,7 @@ describe("prepareEmbeddedAttemptAgentSession", () => {
"publish-system-prompt",
"apply-system-prompt",
"install-terminal-hook",
"install-code-mode-repair",
"stage:agent-session",
]);
expect(hoisted.applyAgentAutoCompactionGuard).toHaveBeenCalledTimes(2);
@@ -229,6 +243,15 @@ describe("prepareEmbeddedAttemptAgentSession", () => {
expect(result.hasDeliveredSourceReply()).toBe(true);
});
it("does not install Code Mode repair when the run kept direct tools", async () => {
const fixture = createInput({ codeModeControlsEnabledForRun: false });
await prepareEmbeddedAttemptAgentSession(fixture.input);
expect(hoisted.installCodeModeRepairHook).not.toHaveBeenCalled();
expect(fixture.events).not.toContain("install-code-mode-repair");
});
it("leaves overflow recovery with the session when no model budget was resolved", async () => {
const fixture = createInput();
fixture.input.attempt = {
@@ -23,6 +23,7 @@ import { applySystemPromptToSession } from "../system-prompt.js";
import { prepareEmbeddedAttemptClientTools } from "./attempt-client-tools.js";
import type { AttemptContextEngine } from "./attempt.context-engine-helpers.js";
import type { EmbeddedAttemptSessionLockController } from "./attempt.session-lock.js";
import { installCodeModeRepairHook } from "./code-mode-repair.js";
import { installMessageToolOnlyTerminalHook } from "./message-tool-terminal.js";
import { notifyToolActivity } from "./tool-activity-heartbeat.js";
import type { EmbeddedRunAttemptParams } from "./types.js";
@@ -185,6 +186,9 @@ export async function prepareEmbeddedAttemptAgentSession(input: {
sourceReplyDeliveryMode: attempt.sourceReplyDeliveryMode,
onDeliveredSourceReply: markSourceReplyDelivered,
});
if (input.clientToolPreparation.codeModeControlsEnabledForRun) {
installCodeModeRepairHook({ agent: activeSession.agent });
}
input.markStage("agent-session");
return {
@@ -0,0 +1,416 @@
import { describe, expect, it, vi } from "vitest";
import type { AfterToolOutcomeContext, Agent, AgentToolResult } from "../../runtime/index.js";
import { installCodeModeRepairHook } from "./code-mode-repair.js";
function outcome(params: {
assistantMessage?: AfterToolOutcomeContext["assistantMessage"];
toolName?: string;
result: AgentToolResult<unknown>;
isError?: boolean;
executionStarted?: boolean;
errorKind?: "argument-validation";
}): AfterToolOutcomeContext {
return {
assistantMessage:
params.assistantMessage ??
({
role: "assistant",
content: [],
timestamp: 1,
} as unknown as AfterToolOutcomeContext["assistantMessage"]),
toolCall: {
type: "toolCall",
id: "call-1",
name: params.toolName ?? "exec",
arguments: {},
},
args: {},
result: params.result,
isError: params.isError ?? false,
executionStarted: params.executionStarted ?? true,
...(params.errorKind ? { errorKind: params.errorKind } : {}),
context: { systemPrompt: "", messages: [], tools: [] },
} as unknown as AfterToolOutcomeContext;
}
function failedResult(params?: {
code?: string;
failurePhase?: "input" | "guest" | "bridge" | "host";
bridgeDispatchStarted?: boolean;
output?: unknown[];
}): AgentToolResult<unknown> {
const details = {
status: "failed",
code: params?.code ?? "internal_error",
error: "guest failed",
failurePhase: params?.failurePhase ?? "guest",
bridgeDispatchStarted: params?.bridgeDispatchStarted ?? false,
...(params?.output ? { output: params.output } : {}),
};
return {
content: [{ type: "text", text: JSON.stringify(details) }],
details,
};
}
function completedResult(): AgentToolResult<unknown> {
return {
content: [{ type: "text", text: '{"status":"completed","value":42}' }],
details: { status: "completed", value: 42 },
};
}
function createAgent(previous?: Agent["afterToolOutcome"]): Agent {
const agent = { afterToolOutcome: previous } as Agent;
installCodeModeRepairHook({ agent });
return agent;
}
describe("installCodeModeRepairHook", () => {
it("offers one repair for a pre-execution argument validation failure", async () => {
const agent = createAgent();
const result = await agent.afterToolOutcome?.(
outcome({
result: { content: [{ type: "text", text: "code is required" }], details: {} },
isError: true,
executionStarted: false,
errorKind: "argument-validation",
}),
);
expect(result).toMatchObject({
isError: true,
terminate: false,
details: {
status: "failed",
code: "invalid_input",
failurePhase: "input",
bridgeDispatchStarted: false,
repair: { allowed: true, remainingAttempts: 1 },
},
});
});
it("does not spend the repair token on successful pure computation", async () => {
const agent = createAgent();
expect(await agent.afterToolOutcome?.(outcome({ result: completedResult() }))).toBeUndefined();
const result = await agent.afterToolOutcome?.(outcome({ result: failedResult() }));
expect(result).toMatchObject({
terminate: false,
details: { repair: { allowed: true, remainingAttempts: 1 } },
});
});
it("terminates when the single repair attempt also fails", async () => {
const agent = createAgent();
await agent.afterToolOutcome?.(outcome({ result: failedResult() }));
const result = await agent.afterToolOutcome?.(outcome({ result: failedResult() }));
expect(result).toMatchObject({
isError: true,
terminate: true,
details: { repair: { allowed: false, remainingAttempts: 0 } },
});
});
it("does not consume the repair on sibling execs from the originating turn", async () => {
const agent = createAgent();
const assistantMessage = {
role: "assistant",
content: [],
timestamp: 1,
} as unknown as AfterToolOutcomeContext["assistantMessage"];
await agent.afterToolOutcome?.(outcome({ assistantMessage, result: failedResult() }));
expect(
await agent.afterToolOutcome?.(outcome({ assistantMessage, result: completedResult() })),
).toBeUndefined();
const siblingFailure = await agent.afterToolOutcome?.(
outcome({ assistantMessage, result: failedResult() }),
);
const correctionFailure = await agent.afterToolOutcome?.(outcome({ result: failedResult() }));
expect(siblingFailure).toMatchObject({
terminate: false,
details: { repair: { allowed: true, remainingAttempts: 1 } },
});
expect(correctionFailure).toMatchObject({
terminate: true,
details: { repair: { allowed: false, remainingAttempts: 0 } },
});
});
it("never offers a retry after bridge dispatch", async () => {
const agent = createAgent();
const result = await agent.afterToolOutcome?.(
outcome({
result: failedResult({
failurePhase: "bridge",
bridgeDispatchStarted: true,
output: [{ type: "text", text: "before dispatch failure" }],
}),
}),
);
const payload = JSON.parse(
String(result?.content?.find((entry) => entry.type === "text")?.text),
) as Record<string, unknown>;
expect(result).toMatchObject({
isError: true,
terminate: true,
details: {
bridgeDispatchStarted: true,
repair: { allowed: false, remainingAttempts: 0 },
},
});
expect(payload.output).toEqual([{ type: "text", text: "before dispatch failure" }]);
});
it("preserves dispatch evidence replaced by an earlier outcome hook", async () => {
const previous = vi.fn(async () => ({
details: {
status: "failed",
code: "internal_error",
error: "rewritten failure",
},
}));
const agent = createAgent(previous);
const result = await agent.afterToolOutcome?.(
outcome({
result: failedResult({
failurePhase: "bridge",
bridgeDispatchStarted: true,
}),
}),
);
expect(result).toMatchObject({
isError: true,
terminate: true,
details: {
bridgeDispatchStarted: true,
repair: { allowed: false, remainingAttempts: 0 },
},
});
});
it("keeps positive dispatch evidence when an earlier hook replaces it with false", async () => {
const previous = vi.fn(async () => ({
details: {
status: "failed",
code: "internal_error",
error: "rewritten failure",
failurePhase: "guest",
bridgeDispatchStarted: false,
},
}));
const agent = createAgent(previous);
const result = await agent.afterToolOutcome?.(
outcome({
result: failedResult({
failurePhase: "bridge",
bridgeDispatchStarted: true,
}),
}),
);
expect(result).toMatchObject({
terminate: true,
details: {
failurePhase: "bridge",
bridgeDispatchStarted: true,
repair: { allowed: false },
},
});
});
it("keeps a bridge failure when an earlier hook rewrites it as success", async () => {
const previous = vi.fn(async () => ({
content: [{ type: "text" as const, text: '{"status":"completed"}' }],
details: { status: "completed" },
isError: false,
terminate: false,
}));
const agent = createAgent(previous);
const result = await agent.afterToolOutcome?.(
outcome({
result: failedResult({
failurePhase: "bridge",
bridgeDispatchStarted: true,
}),
}),
);
expect(result).toMatchObject({
isError: true,
terminate: true,
details: {
status: "failed",
failurePhase: "bridge",
bridgeDispatchStarted: true,
repair: { allowed: false },
},
});
});
it("fails closed when an earlier outcome hook throws", async () => {
const previous = vi.fn(async () => {
throw new Error("hook exploded");
});
const agent = createAgent(previous);
const result = await agent.afterToolOutcome?.(
outcome({
result: failedResult({
failurePhase: "bridge",
bridgeDispatchStarted: true,
}),
}),
);
expect(result).toMatchObject({
isError: true,
terminate: true,
details: {
failurePhase: "bridge",
bridgeDispatchStarted: true,
repair: { allowed: false },
},
});
expect(result?.content).toEqual([
expect.objectContaining({ text: expect.stringContaining("hook exploded") }),
]);
});
it("preserves bounded partial output in the repair payload", async () => {
const agent = createAgent();
const result = await agent.afterToolOutcome?.(
outcome({
result: failedResult({
output: [{ type: "text", text: "before failure" }],
}),
}),
);
const payload = JSON.parse(
String(result?.content?.find((entry) => entry.type === "text")?.text),
) as Record<string, unknown>;
expect(payload.output).toEqual([{ type: "text", text: "before failure" }]);
expect(payload.repair).toMatchObject({ allowed: true, remainingAttempts: 1 });
});
it("fails closed when an executed failure lacks dispatch evidence", async () => {
const agent = createAgent();
const result = await agent.afterToolOutcome?.(
outcome({
result: {
content: [{ type: "text", text: "unknown execution failure" }],
details: {},
},
isError: true,
executionStarted: true,
}),
);
expect(result).toMatchObject({
isError: true,
terminate: true,
details: {
failurePhase: "bridge",
bridgeDispatchStarted: true,
repair: { allowed: false, remainingAttempts: 0 },
},
});
});
it("preserves a terminal decision from an earlier outcome hook", async () => {
const previous = vi.fn(async () => ({ terminate: true }));
const agent = createAgent(previous);
const result = await agent.afterToolOutcome?.(outcome({ result: failedResult() }));
expect(result).toMatchObject({
isError: true,
terminate: true,
details: { repair: { allowed: false, remainingAttempts: 0 } },
});
});
it("keeps an original terminal decision when an earlier hook returns false", async () => {
const previous = vi.fn(async () => ({ terminate: false }));
const agent = createAgent(previous);
const terminalFailure = failedResult();
terminalFailure.terminate = true;
const result = await agent.afterToolOutcome?.(
outcome({
result: terminalFailure,
}),
);
expect(result).toMatchObject({
terminate: true,
details: { repair: { allowed: false, remainingAttempts: 0 } },
});
});
it("keeps an original terminal success when an earlier hook rewrites it", async () => {
const previous = vi.fn(async () => ({
content: [{ type: "text" as const, text: '{"status":"completed","value":"rewritten"}' }],
details: { status: "completed", value: "rewritten" },
terminate: false,
}));
const agent = createAgent(previous);
const terminalSuccess = completedResult();
terminalSuccess.terminate = true;
const result = await agent.afterToolOutcome?.(
outcome({
result: terminalSuccess,
}),
);
expect(result).toMatchObject({
details: { status: "completed", value: "rewritten" },
terminate: true,
});
});
it("terminates failed waits because their bridge work already started", async () => {
const agent = createAgent();
const result = await agent.afterToolOutcome?.(
outcome({
toolName: "wait",
result: failedResult({ failurePhase: "host" }),
}),
);
expect(result).toMatchObject({
isError: true,
terminate: true,
details: { repair: { allowed: false } },
});
});
it("leaves non-Code-Mode tools with the previously installed outcome hook", async () => {
const previous = vi.fn(async () => ({ details: { previous: true } }));
const agent = createAgent(previous);
const context = outcome({ toolName: "read", result: completedResult() });
await expect(agent.afterToolOutcome?.(context)).resolves.toEqual({
details: { previous: true },
});
expect(previous).toHaveBeenCalledWith(context, undefined);
});
});
@@ -0,0 +1,318 @@
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import {
CODE_MODE_EXEC_TOOL_NAME,
CODE_MODE_WAIT_TOOL_NAME,
} from "../../code-mode-control-tools.js";
import type {
AfterToolCallResult,
AfterToolOutcomeContext,
Agent,
AgentToolResult,
} from "../../runtime/index.js";
type CodeModeFailurePhase = "input" | "guest" | "bridge" | "host";
type CodeModeFailure = {
code: string;
error: string;
failurePhase: CodeModeFailurePhase;
bridgeDispatchStarted: boolean;
bridgeDispatchKnown: boolean;
details: Record<string, unknown>;
};
type RepairState = "ready" | "offered" | "consumed";
function resultText(result: AgentToolResult<unknown>): string {
return result.content
.filter((entry): entry is Extract<(typeof result.content)[number], { type: "text" }> => {
return entry.type === "text";
})
.map((entry) => entry.text)
.join("\n")
.trim();
}
function normalizeFailurePhase(
value: unknown,
fallback: CodeModeFailurePhase,
): CodeModeFailurePhase {
return value === "input" || value === "guest" || value === "bridge" || value === "host"
? value
: fallback;
}
function codeModeFailureFromOutcome(context: AfterToolOutcomeContext): CodeModeFailure | undefined {
const details = isRecord(context.result.details) ? context.result.details : {};
if (details.status === "failed") {
const bridgeDispatchStarted = details.bridgeDispatchStarted === true;
return {
code: typeof details.code === "string" ? details.code : "internal_error",
error:
typeof details.error === "string"
? details.error
: resultText(context.result) || "code mode execution failed",
failurePhase: normalizeFailurePhase(
details.failurePhase,
bridgeDispatchStarted ? "bridge" : context.executionStarted ? "guest" : "input",
),
bridgeDispatchStarted,
bridgeDispatchKnown: typeof details.bridgeDispatchStarted === "boolean",
details,
};
}
if (!context.isError) {
return undefined;
}
const argumentValidation =
!context.executionStarted && context.errorKind === "argument-validation";
return {
code: argumentValidation ? "invalid_input" : "internal_error",
error: resultText(context.result) || "code mode execution failed",
failurePhase: argumentValidation ? "input" : "host",
bridgeDispatchStarted: context.executionStarted,
bridgeDispatchKnown: argumentValidation,
details,
};
}
function preserveOriginalDispatchEvidence(
failure: CodeModeFailure | undefined,
original: CodeModeFailure | undefined,
): CodeModeFailure | undefined {
if (!failure) {
return original?.bridgeDispatchStarted ? original : undefined;
}
if (!original) {
return failure;
}
const preserved =
Object.hasOwn(original.details, "output") && !Object.hasOwn(failure.details, "output")
? {
...failure,
details: {
...failure.details,
output: original.details.output,
},
}
: failure;
if (original.bridgeDispatchStarted) {
return {
...preserved,
failurePhase: "bridge",
bridgeDispatchStarted: true,
bridgeDispatchKnown: true,
};
}
if (!original.bridgeDispatchKnown || preserved.bridgeDispatchKnown) {
return preserved;
}
return {
...preserved,
failurePhase: original.failurePhase,
bridgeDispatchStarted: original.bridgeDispatchStarted,
bridgeDispatchKnown: true,
};
}
function renderFailure(params: {
failure: CodeModeFailure;
allowed: boolean;
remainingAttempts: number;
reason: string;
terminate: boolean;
}): AfterToolCallResult {
const repair = {
allowed: params.allowed,
remainingAttempts: params.remainingAttempts,
reason: params.reason,
};
const modelPayload = {
status: "failed",
code: params.failure.code,
error: params.failure.error,
failurePhase: params.failure.failurePhase,
bridgeDispatchStarted: params.failure.bridgeDispatchStarted,
...(Object.hasOwn(params.failure.details, "output")
? { output: params.failure.details.output }
: {}),
repair,
};
return {
content: [{ type: "text", text: JSON.stringify(modelPayload) }],
details: {
...params.failure.details,
status: "failed",
code: params.failure.code,
error: params.failure.error,
failurePhase: params.failure.failurePhase,
bridgeDispatchStarted: params.failure.bridgeDispatchStarted,
repair,
},
isError: true,
terminate: params.terminate,
};
}
function mergePriorOutcome(
context: AfterToolOutcomeContext,
prior: AfterToolCallResult | undefined,
): AfterToolOutcomeContext {
if (!prior) {
return context;
}
return {
...context,
result: {
...context.result,
content: prior.content ?? context.result.content,
details: prior.details ?? context.result.details,
terminate:
context.result.terminate === true || prior.terminate === true
? true
: (prior.terminate ?? context.result.terminate),
},
isError: prior.isError ?? context.isError,
};
}
function hookFailure(
context: AfterToolOutcomeContext,
original: CodeModeFailure | undefined,
error: unknown,
): CodeModeFailure {
return {
code: "internal_error",
error: `Code Mode outcome hook failed: ${error instanceof Error ? error.message : String(error)}`,
failurePhase: original?.bridgeDispatchStarted
? "bridge"
: context.executionStarted
? "host"
: "input",
bridgeDispatchStarted: original?.bridgeDispatchStarted ?? context.executionStarted,
bridgeDispatchKnown: original?.bridgeDispatchKnown ?? !context.executionStarted,
details: original?.details ?? {},
};
}
/** Installs one bounded, side-effect-aware Code Mode repair opportunity. */
export function installCodeModeRepairHook(params: { agent: Agent }): void {
const previousAfterToolOutcome = params.agent.afterToolOutcome?.bind(params.agent);
let repairState: RepairState = "ready";
let repairOfferedBy: AfterToolOutcomeContext["assistantMessage"] | undefined;
params.agent.afterToolOutcome = async (context, signal) => {
const codeModeTool =
context.toolCall.name === CODE_MODE_EXEC_TOOL_NAME ||
context.toolCall.name === CODE_MODE_WAIT_TOOL_NAME;
const originalFailure = codeModeTool ? codeModeFailureFromOutcome(context) : undefined;
let prior: AfterToolCallResult | undefined;
try {
prior = await previousAfterToolOutcome?.(context, signal);
} catch (error) {
if (!codeModeTool) {
throw error;
}
return renderFailure({
failure: hookFailure(context, originalFailure, error),
allowed: false,
remainingAttempts: 0,
reason: "A Code Mode outcome hook failed, so retry safety cannot be established.",
terminate: true,
});
}
if (!codeModeTool) {
return prior;
}
const effective = mergePriorOutcome(context, prior);
const failure = preserveOriginalDispatchEvidence(
codeModeFailureFromOutcome(effective),
originalFailure,
);
if (!failure) {
if (context.result.terminate === true) {
return { ...prior, terminate: true };
}
if (
effective.toolCall.name === CODE_MODE_EXEC_TOOL_NAME &&
repairState === "offered" &&
effective.assistantMessage !== repairOfferedBy
) {
repairState = "consumed";
}
return prior;
}
if (context.result.terminate === true || effective.result.terminate === true) {
repairState = "consumed";
return renderFailure({
failure,
allowed: false,
remainingAttempts: 0,
reason: "The finalized Code Mode outcome is terminal and cannot be repaired.",
terminate: true,
});
}
if (failure.bridgeDispatchStarted || effective.toolCall.name === CODE_MODE_WAIT_TOOL_NAME) {
repairState = "consumed";
return renderFailure({
failure,
allowed: false,
remainingAttempts: 0,
reason:
"A Code Mode bridge call already started; do not retry because nested tools may have side effects.",
terminate: true,
});
}
const repairable =
failure.bridgeDispatchKnown &&
(failure.failurePhase === "input" || failure.failurePhase === "guest") &&
(failure.code === "invalid_input" || failure.code === "internal_error");
if (repairState === "offered" && effective.assistantMessage === repairOfferedBy && repairable) {
return renderFailure({
failure,
allowed: true,
remainingAttempts: 1,
reason:
"Retry exec once with corrected JavaScript or TypeScript. Do not repeat unchanged input.",
terminate: false,
});
}
if (repairState === "offered" || repairState === "consumed") {
repairState = "consumed";
return renderFailure({
failure,
allowed: false,
remainingAttempts: 0,
reason: "The single Code Mode repair attempt is exhausted.",
terminate: true,
});
}
if (!repairable) {
repairState = "consumed";
return renderFailure({
failure,
allowed: false,
remainingAttempts: 0,
reason: "This Code Mode failure is not safely repairable in the current turn.",
terminate: true,
});
}
repairState = "offered";
repairOfferedBy = effective.assistantMessage;
return renderFailure({
failure,
allowed: true,
remainingAttempts: 1,
reason:
"Retry exec once with corrected JavaScript or TypeScript. Do not repeat unchanged input.",
terminate: false,
});
};
}
+1
View File
@@ -29,6 +29,7 @@ export {
export type {
AfterToolCallContext,
AfterToolCallResult,
AfterToolOutcomeContext,
AgentEvent,
AgentMessage,
AgentOptions,
+1
View File
@@ -52,6 +52,7 @@ export {
} from "../../packages/agent-core/src/index.js";
export type {
AfterToolCallResult,
AfterToolOutcomeContext,
AgentEvent,
AgentMessage,
AfterToolCallContext,