diff --git a/CHANGELOG.md b/CHANGELOG.md index cc88c90d7..b2562648b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ ### Fixed +- An OpenAI Responses stream that finishes with a tool call whose `output_item.done` never arrived now ends with an error instead of handing that call to the agent loop. Non-compliant servers that reuse or omit `output_index` (observed with OpenAI-compatible gateways) could otherwise produce duplicate or mixed-up tool calls in one assistant message, which ran the same tool twice and then failed every later turn on replay with `Duplicate value for 'tool_call_id'`. - Recover a normal assistant response that contains neither visible text nor an executable tool call: keep the original message in history unchanged, append one hidden user-role recovery prompt, and continue. Recovery is bounded by a streak of consecutive empty turns. Only a turn with visible text or an executed tool call resets it, and so does any user input, so a long autonomous run is not ended by one transient empty response and a provider error in between does not refill the budget. Automatic compaction runs first when the response also exceeded the context window. - A run that ends without a usable answer reports it on `agent_settled` as `failure` (reason and message), instead of rewriting the assistant message's provider stop reason, so a settled run no longer means success by itself. Subagent lanes and goal continuation act on it, and print/JSON pick their exit code from the same classification, so no consumer decides success from `stopReason` alone. `assistantTerminalFailure()` in `@step-harness/providers` applies the same rule to a message that has not settled yet. The hidden recovery message records the entry it answers in `details.recoversEntryId`. - Summary requests now use the provider's model output limit and remaining-context sizing instead of fixed compaction/branch caps or reserve-derived budgets. Existing reserve arguments remain accepted for call compatibility; automatic trigger and branch source-selection settings are unchanged. diff --git a/packages/providers/src/api/openai-responses-shared.ts b/packages/providers/src/api/openai-responses-shared.ts index ae9745361..3ea14cbe8 100644 --- a/packages/providers/src/api/openai-responses-shared.ts +++ b/packages/providers/src/api/openai-responses-shared.ts @@ -758,6 +758,21 @@ export async function processResponsesStream( if (!sawTerminalResponseEvent) { throw new Error("OpenAI Responses stream ended before a terminal response event"); } + // The agent runs every tool call in the final message. Refuse to hand over calls whose + // output_item.done never arrived: their arguments may be cut off or mixed up, e.g. when a + // non-compliant server reuses or omits output_index. Finalized calls drop their scratch + // buffers, so a surviving buffer means this call never completed. + if (output.stopReason === "toolUse") { + for (const block of output.content) { + if (block.type !== "toolCall") continue; + const toolCall = block as StreamingToolCall; + if (toolCall.partialJson !== undefined || toolCall.customInput !== undefined) { + throw new Error( + `OpenAI Responses stream completed with an unfinished tool call: ${toolCall.name} (${toolCall.id})`, + ); + } + } + } } function mapStopReason( diff --git a/packages/providers/test/openai-responses-terminal-event.test.ts b/packages/providers/test/openai-responses-terminal-event.test.ts index a6c2fd94c..f4020a2f6 100644 --- a/packages/providers/test/openai-responses-terminal-event.test.ts +++ b/packages/providers/test/openai-responses-terminal-event.test.ts @@ -203,6 +203,70 @@ async function* createPhasedMessageEvents( } as ResponseStreamEvent; } +// A server that omits output_index entirely, then sends both done events after all deltas. +async function* createToolCallsWithoutOutputIndexEvents(): AsyncIterable { + const call = (n: string) => ({ type: "function_call", id: `fc_${n}`, call_id: `call_${n}`, name: "bash" }); + const events = [ + { type: "response.output_item.added", item: { ...call("a"), arguments: "" } }, + { type: "response.function_call_arguments.delta", item_id: "fc_a", delta: '{"command":"echo a"}' }, + { type: "response.output_item.added", item: { ...call("b"), arguments: "" } }, + { type: "response.function_call_arguments.delta", item_id: "fc_b", delta: '{"command":"echo b"}' }, + { type: "response.output_item.done", item: { ...call("a"), arguments: '{"command":"echo a"}' } }, + { type: "response.output_item.done", item: { ...call("b"), arguments: '{"command":"echo b"}' } }, + { type: "response.completed", response: { id: "resp_no_output_index", status: "completed" } }, + ]; + for (const event of events) yield event as unknown as ResponseStreamEvent; +} + +// A streamed call whose output_item.done never arrives before the terminal event. +async function* createUnfinishedToolCallEvents(): AsyncIterable { + yield { + type: "response.output_item.added", + sequence_number: 0, + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "bash", arguments: "" }, + } as ResponseStreamEvent; + yield { + type: "response.function_call_arguments.delta", + sequence_number: 1, + output_index: 0, + item_id: "fc_1", + delta: '{"command":"rm -rf /tmp/build', + } as ResponseStreamEvent; + yield { + type: "response.completed", + sequence_number: 2, + response: { id: "resp_unfinished", status: "completed" }, + } as ResponseStreamEvent; +} + +// A server that reuses an already-closed output_index for a later item, then re-sends the +// earlier call's output_item.done. The slot map rebuilds that call, which leaves a second +// content block carrying the same tool call id. +async function* createReusedOutputIndexEvents(): AsyncIterable { + const call = (n: string) => ({ type: "function_call", id: `fc_${n}`, call_id: `call_${n}`, name: "read_file" }); + const events = [ + { type: "response.output_item.added", output_index: 0, item: { ...call("a"), arguments: "" } }, + { type: "response.output_item.added", output_index: 1, item: { ...call("b"), arguments: "" } }, + { type: "response.function_call_arguments.delta", output_index: 0, item_id: "fc_a", delta: '{"path":"/a"}' }, + { type: "response.function_call_arguments.delta", output_index: 1, item_id: "fc_b", delta: '{"path":"/b"}' }, + { + type: "response.output_item.added", + output_index: 0, + item: { type: "message", id: "msg_reused", role: "assistant", status: "in_progress", content: [] }, + }, + { + type: "response.output_item.done", + output_index: 0, + item: { type: "message", id: "msg_reused", role: "assistant", status: "completed", content: [] }, + }, + { type: "response.output_item.done", output_index: 0, item: { ...call("a"), arguments: '{"path":"/a"}' } }, + { type: "response.output_item.done", output_index: 1, item: { ...call("b"), arguments: '{"path":"/b"}' } }, + { type: "response.completed", response: { id: "resp_reused_index", status: "completed" } }, + ]; + for (const event of events) yield event as unknown as ResponseStreamEvent; +} + describe("OpenAI Responses terminal event handling", () => { it("rejects streams that end before a terminal response event", async () => { const model = createModel(); @@ -214,6 +278,36 @@ describe("OpenAI Responses terminal event handling", () => { ); }); + it("rejects completed streams whose tool call never received output_item.done", async () => { + const model = createModel(); + const output = createOutput(model); + const stream = new AssistantMessageEventStream(); + + await expect(processResponsesStream(createUnfinishedToolCallEvents(), output, stream, model)).rejects.toThrow( + "OpenAI Responses stream completed with an unfinished tool call: bash (call_1|fc_1)", + ); + }); + + it("rejects parallel tool calls without output_index instead of running mixed-up calls", async () => { + const model = createModel(); + const output = createOutput(model); + const stream = new AssistantMessageEventStream(); + + await expect( + processResponsesStream(createToolCallsWithoutOutputIndexEvents(), output, stream, model), + ).rejects.toThrow("OpenAI Responses stream completed with an unfinished tool call: bash (call_a|fc_a)"); + }); + + it("rejects tool calls rebuilt from a reused output_index", async () => { + const model = createModel(); + const output = createOutput(model); + const stream = new AssistantMessageEventStream(); + + await expect(processResponsesStream(createReusedOutputIndexEvents(), output, stream, model)).rejects.toThrow( + "OpenAI Responses stream completed with an unfinished tool call: read_file (call_a|fc_a)", + ); + }); + it("emits an error final result when the wrapper stream ends before a terminal response event", async () => { const model = createModel(); const context: Context = {