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(