Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions packages/providers/src/api/openai-responses-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,21 @@ export async function processResponsesStream<TApi extends Api>(
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(
Expand Down
94 changes: 94 additions & 0 deletions packages/providers/test/openai-responses-terminal-event.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ResponseStreamEvent> {
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<ResponseStreamEvent> {
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<ResponseStreamEvent> {
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();
Expand All @@ -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 = {
Expand Down
Loading