mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(agents): bound Code Mode repair retries (#115729)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export {
|
||||
export type {
|
||||
AfterToolCallContext,
|
||||
AfterToolCallResult,
|
||||
AfterToolOutcomeContext,
|
||||
AgentEvent,
|
||||
AgentMessage,
|
||||
AgentOptions,
|
||||
|
||||
@@ -52,6 +52,7 @@ export {
|
||||
} from "../../packages/agent-core/src/index.js";
|
||||
export type {
|
||||
AfterToolCallResult,
|
||||
AfterToolOutcomeContext,
|
||||
AgentEvent,
|
||||
AgentMessage,
|
||||
AfterToolCallContext,
|
||||
|
||||
Reference in New Issue
Block a user