From 1b7aa90c974aa208da12ea2361d6895823c9fb79 Mon Sep 17 00:00:00 2001 From: SunnyShu Date: Sat, 1 Aug 2026 03:30:13 +0800 Subject: [PATCH] fix(agents): preserve tool-call pairing after mid-turn abort (#116642) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [AI] fix(agents): emit aborted tool results for skipped tool calls on mid-turn abort When an abort fires mid-batch in executeToolCalls (after the assistant message with tool_use is committed but before all tool_results are written), the sequential and parallel dispatch loops break out and skip the remaining tool calls. The committed assistant turn retains N tool_use blocks but only M < N tool_results land in context.messages, leaving orphaned tool_use that corrupts retries/continuation and triggers provider 400 errors on providers that do not synthesize missing results (allowSyntheticToolResults=false, e.g. openai-completions/DeepSeek). Emit aborted tool results (createErrorToolResult("Operation aborted")) for the skipped tail in both executeToolCallsSequential and executeToolCallsParallel so every tool_use keeps a paired tool_result. This complements the existing write-side guard (which only covers synthetic-enabled providers) and the persisted replay repair. The aborted tail outcomes are routed through finalizeToolCallOutcome (via a shared finalizeAbortedToolCall helper) so config.afterToolOutcome hooks (audit, redaction, metadata, error-normalization) observe these skipped calls just like every immediate or executed outcome, instead of bypassing the outcome contract. Regression tests assert afterToolOutcome fires for every skipped call in both dispatch modes. Fixes #116379 Co-Authored-By: Maas * [AI] fix(agents): emit tool_execution_start before aborted end for skipped calls The abort-tail backfill added in #116379 emits tool_execution_end (and a paired tool_result) for tool calls the dispatch loop never reached, but it skipped the matching tool_execution_start. Channel/client subscribers that pair start→end events received an end for an unknown tool-call id during abort recovery. Emit tool_execution_start for each skipped call before its aborted end/result, mirroring the start event every dispatched (including immediate non-executed) call already emits. Covers both sequential and parallel dispatch, with regression assertions that every skipped call has a start before its end and that start/end counts stay paired. Co-Authored-By: Maas * fix(agents): complete aborted tool tails safely Fixes #116379 --------- Co-authored-by: Maas Co-authored-by: Vincent Koc --- packages/agent-core/src/agent-loop.test.ts | 252 ++++++++++++++++++ packages/agent-core/src/agent-loop.ts | 82 ++++++ .../run/code-mode-repair.test.ts | 22 ++ .../run/code-mode-repair.ts | 3 + 4 files changed, 359 insertions(+) diff --git a/packages/agent-core/src/agent-loop.test.ts b/packages/agent-core/src/agent-loop.test.ts index 2391bd3807c7..8023ffb3ed9f 100644 --- a/packages/agent-core/src/agent-loop.test.ts +++ b/packages/agent-core/src/agent-loop.test.ts @@ -1668,6 +1668,258 @@ describe("agentLoop tool termination", () => { expect(events.at(-1)).toMatchObject({ type: "agent_end" }); }); + it("emits aborted tool results for skipped tool calls on sequential abort (#116379)", async () => { + const controller = new AbortController(); + let streamCalls = 0; + const streamFn: StreamFn = () => { + streamCalls += 1; + if (streamCalls > 1) { + throw new Error("model was called after abort"); + } + const stream = createAssistantMessageEventStream(); + queueMicrotask(() => { + const message = makeAssistantMessage([ + { type: "toolCall", id: "call-first", name: "first_tool", arguments: {} }, + { type: "toolCall", id: "call-second", name: "second_tool", arguments: {} }, + { type: "toolCall", id: "call-third", name: "third_tool", arguments: {} }, + ]); + stream.push({ type: "done", reason: "toolUse", message }); + stream.end(); + }); + return stream; + }; + const firstTool: AgentTool = { + name: "first_tool", + label: "first_tool", + description: "Aborts the run mid-batch", + parameters: Type.Object({}, { additionalProperties: false }), + executionMode: "sequential", + execute: async () => { + controller.abort(new Error("user aborted")); + return { + content: [{ type: "text", text: "first ran" }], + details: { aborted: true }, + }; + }, + }; + const skippedTool: AgentTool = { + name: "second_tool", + label: "second_tool", + description: "Should be skipped by abort", + parameters: Type.Object({}, { additionalProperties: false }), + executionMode: "sequential", + hideFromChannelProgress: true, + execute: async () => { + throw new Error("second_tool should never execute"); + }, + }; + const thirdTool: AgentTool = { + ...skippedTool, + name: "third_tool", + label: "third_tool", + }; + + // afterToolOutcome must observe every committed tool call, including the + // aborted tail the dispatch loop skipped — otherwise audit/redaction hooks + // silently miss the repaired calls (#116379). + const afterToolOutcome = vi.fn(async () => undefined); + const events: AgentEvent[] = []; + const messages = await runAgentLoop( + [{ role: "user", content: "abort mid-batch", timestamp: 1 }], + { + systemPrompt: "", + messages: [], + tools: [firstTool, skippedTool, thirdTool], + }, + { ...config, toolExecution: "sequential", afterToolOutcome }, + (event) => { + events.push(event); + }, + controller.signal, + streamFn, + ); + + // The assistant turn committed three tool_use blocks; every one must have a + // matching tool_result so the history has no orphaned tool_use. + const toolResultMessages = messages.filter((message) => message.role === "toolResult"); + const toolResultIds = toolResultMessages.map( + (message) => (message as Extract).toolCallId, + ); + expect(toolResultIds).toEqual(["call-first", "call-second", "call-third"]); + // The first tool produced a real result; the skipped tail got aborted results. + expect(toolResultMessages[0]).toMatchObject({ toolCallId: "call-first", isError: false }); + expect(toolResultMessages[1]).toMatchObject({ toolCallId: "call-second", isError: true }); + expect(toolResultMessages[2]).toMatchObject({ toolCallId: "call-third", isError: true }); + expect( + (toolResultMessages[1] as Extract).content, + ).toContainEqual({ type: "text", text: "Operation aborted" }); + // The outcome hook observed all three calls, including the two skipped tail + // calls, with the aborted marker. + expect(afterToolOutcome).toHaveBeenCalledTimes(3); + expect(afterToolOutcome).toHaveBeenCalledWith( + expect.objectContaining({ + toolCall: expect.objectContaining({ id: "call-second" }), + isError: true, + executionStarted: false, + }), + controller.signal, + ); + expect(afterToolOutcome).toHaveBeenCalledWith( + expect.objectContaining({ + toolCall: expect.objectContaining({ id: "call-third" }), + isError: true, + executionStarted: false, + }), + controller.signal, + ); + // Every skipped tail call emits a tool_execution_start before its + // tool_execution_end, preserving the lifecycle pairing every dispatched + // call already has — otherwise channel/client subscribers receive an end + // event for an unknown tool-call id (#116379). + for (const skippedId of ["call-second", "call-third"]) { + const startIdx = events.findIndex( + (event) => + event.type === "tool_execution_start" && + (event as Extract).toolCallId === skippedId, + ); + const endIdx = events.findIndex( + (event) => + event.type === "tool_execution_end" && + (event as Extract).toolCallId === skippedId, + ); + expect(startIdx).toBeGreaterThanOrEqual(0); + expect(endIdx).toBeGreaterThan(startIdx); + expect( + (events[endIdx] as Extract).executionStarted, + ).toBe(false); + expect(events[startIdx]).toMatchObject({ hideFromChannelProgress: true }); + expect(events[endIdx]).toMatchObject({ hideFromChannelProgress: true }); + } + expect(events.filter((event) => event.type === "tool_execution_start")).toHaveLength(3); + expect(events.filter((event) => event.type === "tool_execution_end")).toHaveLength(3); + }); + + it("emits aborted tool results for skipped tool calls on parallel abort (#116379)", async () => { + const controller = new AbortController(); + let streamCalls = 0; + const streamFn: StreamFn = () => { + streamCalls += 1; + if (streamCalls > 1) { + throw new Error("model was called after abort"); + } + const stream = createAssistantMessageEventStream(); + queueMicrotask(() => { + const message = makeAssistantMessage([ + { type: "toolCall", id: "p-first", name: "p_first_tool", arguments: {} }, + { type: "toolCall", id: "p-second", name: "p_second_tool", arguments: {} }, + { type: "toolCall", id: "p-third", name: "p_third_tool", arguments: {} }, + ]); + stream.push({ type: "done", reason: "toolUse", message }); + stream.end(); + }); + return stream; + }; + const firstTool: AgentTool = { + name: "p_first_tool", + label: "p_first_tool", + description: "Aborts the run mid-batch", + parameters: Type.Object({}, { additionalProperties: false }), + execute: async () => { + controller.abort(new Error("user aborted")); + return { + content: [{ type: "text", text: "first ran" }], + details: { aborted: true }, + }; + }, + }; + const skippedTool: AgentTool = { + name: "p_second_tool", + label: "p_second_tool", + description: "Should be skipped by abort", + parameters: Type.Object({}, { additionalProperties: false }), + hideFromChannelProgress: true, + execute: async () => { + throw new Error("p_second_tool should never execute"); + }, + }; + const thirdTool: AgentTool = { + ...skippedTool, + name: "p_third_tool", + label: "p_third_tool", + }; + + // afterToolOutcome must observe every committed tool call, including the + // aborted tail the dispatch loop skipped — otherwise audit/redaction hooks + // silently miss the repaired calls (#116379). + const afterToolOutcome = vi.fn(async () => undefined); + const events: AgentEvent[] = []; + const messages = await runAgentLoop( + [{ role: "user", content: "abort mid-batch parallel", timestamp: 1 }], + { + systemPrompt: "", + messages: [], + tools: [firstTool, skippedTool, thirdTool], + }, + { ...config, toolExecution: "parallel", afterToolOutcome }, + (event) => { + events.push(event); + }, + controller.signal, + streamFn, + ); + + const toolResultMessages = messages.filter((message) => message.role === "toolResult"); + const toolResultIds = toolResultMessages.map( + (message) => (message as Extract).toolCallId, + ); + expect(toolResultIds.toSorted()).toEqual(["p-first", "p-second", "p-third"]); + // Every tool_use is paired with a tool_result — no orphaned tool_use. + expect(toolResultMessages).toHaveLength(3); + // The outcome hook observed all three calls, including the two skipped tail + // calls, with the aborted marker. + expect(afterToolOutcome).toHaveBeenCalledTimes(3); + expect(afterToolOutcome).toHaveBeenCalledWith( + expect.objectContaining({ + toolCall: expect.objectContaining({ id: "p-second" }), + isError: true, + executionStarted: false, + }), + controller.signal, + ); + expect(afterToolOutcome).toHaveBeenCalledWith( + expect.objectContaining({ + toolCall: expect.objectContaining({ id: "p-third" }), + isError: true, + executionStarted: false, + }), + controller.signal, + ); + // Every tool call — dispatched or skipped — emits a tool_execution_start + // before its tool_execution_end, so channel/client subscribers never see an + // end event for an unknown tool-call id (#116379). + for (const toolCallId of ["p-first", "p-second", "p-third"]) { + const startIdx = events.findIndex( + (event) => + event.type === "tool_execution_start" && + (event as Extract).toolCallId === + toolCallId, + ); + const endIdx = events.findIndex( + (event) => + event.type === "tool_execution_end" && + (event as Extract).toolCallId === toolCallId, + ); + expect(startIdx).toBeGreaterThanOrEqual(0); + expect(endIdx).toBeGreaterThan(startIdx); + if (toolCallId !== "p-first") { + expect(events[startIdx]).toMatchObject({ hideFromChannelProgress: true }); + expect(events[endIdx]).toMatchObject({ hideFromChannelProgress: true }); + } + } + expect(events.filter((event) => event.type === "tool_execution_start")).toHaveLength(3); + expect(events.filter((event) => event.type === "tool_execution_end")).toHaveLength(3); + }); + it("skips interrupted-turn guidance when the abort reason marks a turn handoff", async () => { const controller = new AbortController(); let streamCalls = 0; diff --git a/packages/agent-core/src/agent-loop.ts b/packages/agent-core/src/agent-loop.ts index 3a0085e03539..6fb938b8c2ea 100644 --- a/packages/agent-core/src/agent-loop.ts +++ b/packages/agent-core/src/agent-loop.ts @@ -724,6 +724,25 @@ async function executeToolCallsSequential( messages.push(toolResultMessage); if (signal?.aborted) { + // Complete the skipped tail through the normal lifecycle and outcome hook + // so the committed tool-call turn stays paired and subscriber-safe. + for (let i = finalizedCalls.length; i < toolCalls.length; i++) { + const skippedToolCall = toolCalls[i]; + if (!skippedToolCall) { + continue; + } + const completed = await completeAbortedToolCall( + currentContext, + assistantMessage, + skippedToolCall, + resolvedToolCalls, + config, + signal, + emit, + ); + finalizedCalls.push(completed.finalized); + messages.push(completed.message); + } break; } } @@ -827,6 +846,28 @@ async function executeToolCallsParallel( messages.push(toolResultMessage); } + // Complete calls skipped before queueing through the same lifecycle contract + // as the sequential path. + if (signal?.aborted && orderedFinalizedCalls.length < toolCalls.length) { + for (let i = orderedFinalizedCalls.length; i < toolCalls.length; i++) { + const skippedToolCall = toolCalls[i]; + if (!skippedToolCall) { + continue; + } + const completed = await completeAbortedToolCall( + currentContext, + assistantMessage, + skippedToolCall, + resolvedToolCalls, + config, + signal, + emit, + ); + orderedFinalizedCalls.push(completed.finalized); + messages.push(completed.message); + } + } + return { messages, terminate: shouldTerminateToolBatch(orderedFinalizedCalls), @@ -1221,6 +1262,47 @@ async function finalizeToolCallOutcome( } } +async function completeAbortedToolCall( + currentContext: AgentContext, + assistantMessage: AssistantMessage, + toolCall: AgentToolCall, + resolvedToolCalls: Map, + config: AgentLoopConfig, + signal: AbortSignal | undefined, + emit: AgentEventSink, +): Promise<{ finalized: FinalizedToolCallOutcome; message: ToolResultMessage }> { + const hideFromChannelProgress = hidesToolCallFromChannelProgress( + currentContext, + toolCall, + resolvedToolCalls, + ); + await emit({ + type: "tool_execution_start", + toolCallId: toolCall.id, + toolName: toolCall.name, + args: toolCall.arguments, + ...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}), + }); + const finalized = await finalizeToolCallOutcome( + currentContext, + assistantMessage, + { + toolCall, + result: createErrorToolResult("Operation aborted"), + isError: true, + executionStarted: false, + ...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}), + }, + toolCall.arguments, + config, + signal, + ); + await emitToolExecutionEnd(finalized, emit); + const message = createToolResultMessage(finalized); + await emitToolResultMessage(message, emit); + return { finalized, message }; +} + function createErrorToolResult(message: string): AgentToolResult { return { content: [{ type: "text", text: message }], 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 index 365ec15cbc5d..748983d0459a 100644 --- a/src/agents/embedded-agent-runner/run/code-mode-repair.test.ts +++ b/src/agents/embedded-agent-runner/run/code-mode-repair.test.ts @@ -403,6 +403,28 @@ describe("installCodeModeRepairHook", () => { }); }); + it("preserves a wait skipped by abort without spending the repair token", async () => { + const previous = vi.fn(async () => undefined); + const agent = createAgent(previous); + const controller = new AbortController(); + controller.abort(); + const context = outcome({ + toolName: "wait", + result: { content: [{ type: "text", text: "Operation aborted" }], details: {} }, + isError: true, + executionStarted: false, + }); + + await expect(agent.afterToolOutcome?.(context, controller.signal)).resolves.toBeUndefined(); + expect(previous).toHaveBeenCalledWith(context, controller.signal); + await expect( + agent.afterToolOutcome?.(outcome({ result: failedResult() })), + ).resolves.toMatchObject({ + terminate: false, + details: { repair: { allowed: true, remainingAttempts: 1 } }, + }); + }); + 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); diff --git a/src/agents/embedded-agent-runner/run/code-mode-repair.ts b/src/agents/embedded-agent-runner/run/code-mode-repair.ts index 25bab496805b..08abe0d72be1 100644 --- a/src/agents/embedded-agent-runner/run/code-mode-repair.ts +++ b/src/agents/embedded-agent-runner/run/code-mode-repair.ts @@ -224,6 +224,9 @@ export function installCodeModeRepairHook(params: { agent: Agent }): void { if (!codeModeTool) { return prior; } + if (signal?.aborted && !context.executionStarted) { + return prior; + } const effective = mergePriorOutcome(context, prior); const failure = preserveOriginalDispatchEvidence(