diff --git a/CHANGELOG.md b/CHANGELOG.md index 49c8f9205786..42530dacff34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/agent-core/src/agent-loop.test.ts b/packages/agent-core/src/agent-loop.test.ts index 15296c95b85a..d5bd917c3b2b 100644 --- a/packages/agent-core/src/agent-loop.test.ts +++ b/packages/agent-core/src/agent-loop.test.ts @@ -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 => + 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" }); }); diff --git a/packages/agent-core/src/agent-loop.ts b/packages/agent-core/src/agent-loop.ts index c2583f88b564..3e2b153674f3 100644 --- a/packages/agent-core/src/agent-loop.ts +++ b/packages/agent-core/src/agent-loop.ts @@ -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 { + 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 { diff --git a/packages/agent-core/src/agent.ts b/packages/agent-core/src/agent.ts index a884bb675ce4..e876ea0c587a 100644 --- a/packages/agent-core/src/agent.ts +++ b/packages/agent-core/src/agent.ts @@ -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; + /** Hook that may alter any finalized tool outcome, including pre-execution failures. */ + afterToolOutcome?: ( + context: AfterToolOutcomeContext, + signal?: AbortSignal, + ) => Promise; /** 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; + public afterToolOutcome?: ( + context: AfterToolOutcomeContext, + signal?: AbortSignal, + ) => Promise; public prepareNextTurn?: ( signal?: AbortSignal, ) => Promise | 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) => { diff --git a/packages/agent-core/src/types.ts b/packages/agent-core/src/types.ts index 0e84b8d77ed8..42de7a3e5a4b 100644 --- a/packages/agent-core/src/types.ts +++ b/packages/agent-core/src/types.ts @@ -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; + /** 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; + + /** + * 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; } /** diff --git a/src/agents/code-mode-execution.ts b/src/agents/code-mode-execution.ts index 4e76a28baa36..cf409878d5e0 100644 --- a/src/agents/code-mode-execution.ts +++ b/src/agents/code-mode-execution.ts @@ -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), diff --git a/src/agents/code-mode-runtime.ts b/src/agents/code-mode-runtime.ts index d4308d343c43..f90d47e2584d 100644 --- a/src/agents/code-mode-runtime.ts +++ b/src/agents/code-mode-runtime.ts @@ -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 + | Extract | { status: "failed"; error: string; code: CodeModeFailureCode; + failurePhase: CodeModeFailurePhase; + bridgeDispatchStarted: boolean; output: unknown[]; }; diff --git a/src/agents/code-mode-worker-types.ts b/src/agents/code-mode-worker-types.ts index de2b01be095b..d4d7b5030e5f 100644 --- a/src/agents/code-mode-worker-types.ts +++ b/src/agents/code-mode-worker-types.ts @@ -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; + bridgeDispatchStarted: false; output: unknown[]; }; diff --git a/src/agents/code-mode-worker.ts b/src/agents/code-mode-worker.ts index ac5aa5bd6048..2b23e982f36e 100644 --- a/src/agents/code-mode-worker.ts +++ b/src/agents/code-mode-worker.ts @@ -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)); diff --git a/src/agents/code-mode.bridge.test.ts b/src/agents/code-mode.bridge.test.ts index f5aecbe7bb9a..f299a2388050 100644 --- a/src/agents/code-mode.bridge.test.ts +++ b/src/agents/code-mode.bridge.test.ts @@ -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. diff --git a/src/agents/code-mode.limits.test.ts b/src/agents/code-mode.limits.test.ts index 695bc926ac27..7f6cb3bb977f 100644 --- a/src/agents/code-mode.limits.test.ts +++ b/src/agents/code-mode.limits.test.ts @@ -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({ diff --git a/src/agents/code-mode.replay.test.ts b/src/agents/code-mode.replay.test.ts index eab788bedd4a..37d78a8aa4ab 100644 --- a/src/agents/code-mode.replay.test.ts +++ b/src/agents/code-mode.replay.test.ts @@ -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 { diff --git a/src/agents/code-mode.test-support.ts b/src/agents/code-mode.test-support.ts index 4f374f47d4ab..36a48a265ece 100644 --- a/src/agents/code-mode.test-support.ts +++ b/src/agents/code-mode.test-support.ts @@ -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< diff --git a/src/agents/code-mode.typescript.test.ts b/src/agents/code-mode.typescript.test.ts index 3abd6252934b..99ffc8ad039b 100644 --- a/src/agents/code-mode.typescript.test.ts +++ b/src/agents/code-mode.typescript.test.ts @@ -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); diff --git a/src/agents/code-mode.worker.ts b/src/agents/code-mode.worker.ts index dbfd200f4ba3..4c9a040618cc 100644 --- a/src/agents/code-mode.worker.ts +++ b/src/agents/code-mode.worker.ts @@ -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 { status: "failed", error: "invalid code mode worker input", code: "invalid_input", + failurePhase: "input", + bridgeDispatchStarted: false, output: [], }; } @@ -518,18 +520,23 @@ async function main(): Promise { 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 : [], }; } diff --git a/src/agents/embedded-agent-runner/run/attempt-session.test.ts b/src/agents/embedded-agent-runner/run/attempt-session.test.ts index 4755d450fa29..2ad8cf29c709 100644 --- a/src/agents/embedded-agent-runner/run/attempt-session.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-session.test.ts @@ -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 = { diff --git a/src/agents/embedded-agent-runner/run/attempt-session.ts b/src/agents/embedded-agent-runner/run/attempt-session.ts index 89c86ebf4cdf..38ba043130cc 100644 --- a/src/agents/embedded-agent-runner/run/attempt-session.ts +++ b/src/agents/embedded-agent-runner/run/attempt-session.ts @@ -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 { diff --git a/src/agents/embedded-agent-runner/run/code-mode-repair.test.ts b/src/agents/embedded-agent-runner/run/code-mode-repair.test.ts new file mode 100644 index 000000000000..365ec15cbc5d --- /dev/null +++ b/src/agents/embedded-agent-runner/run/code-mode-repair.test.ts @@ -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; + 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 { + 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 { + 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; + + 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; + + 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); + }); +}); diff --git a/src/agents/embedded-agent-runner/run/code-mode-repair.ts b/src/agents/embedded-agent-runner/run/code-mode-repair.ts new file mode 100644 index 000000000000..25bab496805b --- /dev/null +++ b/src/agents/embedded-agent-runner/run/code-mode-repair.ts @@ -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; +}; + +type RepairState = "ready" | "offered" | "consumed"; + +function resultText(result: AgentToolResult): 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, + }); + }; +} diff --git a/src/agents/runtime/index.ts b/src/agents/runtime/index.ts index faa2db4ad3fb..7a76a78a956c 100644 --- a/src/agents/runtime/index.ts +++ b/src/agents/runtime/index.ts @@ -29,6 +29,7 @@ export { export type { AfterToolCallContext, AfterToolCallResult, + AfterToolOutcomeContext, AgentEvent, AgentMessage, AgentOptions, diff --git a/src/plugin-sdk/agent-core.ts b/src/plugin-sdk/agent-core.ts index 54f140ebb95c..b607821f3f7f 100644 --- a/src/plugin-sdk/agent-core.ts +++ b/src/plugin-sdk/agent-core.ts @@ -52,6 +52,7 @@ export { } from "../../packages/agent-core/src/index.js"; export type { AfterToolCallResult, + AfterToolOutcomeContext, AgentEvent, AgentMessage, AfterToolCallContext,