Skip to content

Chat Completions streaming: a reasoning item that arrives after the first text/tool chunk reuses output_index 0 and desynchronizes announced indexes from response.completed.output #5041

Closed

Description

@BlueX888

Please read this first

Describe the bug

_StreamOutputLayout latches every output slot from _reasoning_output_count(state), which reports 1 only if a reasoning item already exists:

@staticmethod
def _reasoning_output_count(state: StreamingState) -> int:
    return 1 if state.reasoning_content_index_and_output is not None else 0

The reasoning item is created lazily, the first time a thinking block / reasoning_content / reasoning delta arrives (lines 726, 749, 805), and those events hardcode output_index=0. If a text or tool-call chunk has already been streamed, that slot is already taken. Consequences in response.completed.output:

  1. Two different items are announced with output_index == 0 (the assistant message or a function call, then the reasoning item).
  2. _reasoning_output_count flips from 0 to 1 after the layout has latched, so assistant_message_output_idx - _reasoning_output_count(state) becomes -1. function_calls_before_message (line 255) then does list(state.function_calls.values())[:-1], silently returning all but the last function call instead of an empty prefix, and function_calls_after_message starts at -1, i.e. at the first call.
  3. The final assembly appends the reasoning item first unconditionally (line 1291), so an item announced at index 1 or 2 lands at list position 0.

Only the reasoning-first ordering (reasoning, text, …) is correct today; that is the only ordering currently covered by tests.

Debug information

  • Agents SDK version: 0.22.2 (pyproject.toml; src/agents/version.py resolves importlib.metadata.version("openai-agents"))
  • Related library versions: not applicable — the repro drives ChatCmplStreamHandler with synthetic ChatCompletionChunks, no network and no API key
  • Python version: 3.13.15 (uv run python -V)
  • Operating system: macOS 26.6.2 (Darwin 25.6.0)
  • Model and model provider: model="claude-sonnet-4-5" passed to handle_stream; the provider delta fields exercised are reasoning_content (DeepSeek-style, lines 739-810) — the Anthropic thinking_blocks path (lines 714-737) and the third-party reasoning path (lines 787-810) all create the item the same lazy way
  • Does the issue reproduce with the latest Agents SDK release? Yes — reproduced on pristine main at fbf59a40e9da5adb88d370fefaeaae0478376d4a
  • Does the issue occur consistently or intermittently? Consistently, for any stream that emits reasoning after the first visible chunk

Reachability: OpenAIChatCompletionsModel.stream_response -> ChatCmplStreamHandler.handle_stream (src/agents/models/openai_chatcompletions.py:489). Any provider that interleaves thinking with text or tool calls is affected — e.g. Anthropic Claude 4 interleaved thinking, and DeepSeek-style reasoning_content streams that emit thinking between tool calls.

Repro steps

Self-contained; no network, no API key. Save as probe_matrix.py at the repository root and run uv run python probe_matrix.py.

"""Check that every announced output_index matches the item's position in response.completed.output."""

import asyncio
import itertools
from typing import Any, AsyncIterator, cast

from openai.types.chat import ChatCompletionChunk
from openai.types.chat.chat_completion_chunk import Choice, ChoiceDelta
from openai.types.responses import Response
from openai.types.responses.response_completed_event import ResponseCompletedEvent

from agents.models.chatcmpl_stream_handler import ChatCmplStreamHandler


def _empty_response() -> Response:
    return Response(
        id="resp-id", created_at=0, model="fake-model", object="response",
        output=[], tool_choice="none", tools=[], parallel_tool_calls=False,
    )


async def _stream(*chunks: ChatCompletionChunk) -> AsyncIterator[ChatCompletionChunk]:
    for chunk in chunks:
        yield chunk


def _chunk(payload: dict[str, Any], finish_reason: str | None = None) -> ChatCompletionChunk:
    return ChatCompletionChunk(
        id="chunk-id", created=1, model="fake-model", object="chat.completion.chunk",
        choices=[Choice(index=0, delta=ChoiceDelta.model_validate(payload),
                        finish_reason=cast(Any, finish_reason))],
    )


TOOL = {"tool_calls": [{"index": 0, "id": "call_1", "type": "function",
                        "function": {"name": "lookup", "arguments": "{}"}}]}
TEXT = {"role": "assistant", "content": "hi"}
REASONING = {"reasoning_content": "think"}


def _key(item: Any) -> str:
    return {"message": "text", "function_call": "tool", "reasoning": "reasoning"}.get(
        item.type, item.type
    )


async def check(order: tuple[str, ...]) -> None:
    pieces = {"text": TEXT, "tool": TOOL, "reasoning": REASONING}
    chunks = [_chunk(pieces[name]) for name in order]
    chunks.append(_chunk({}, finish_reason="tool_calls"))
    events = [e async for e in ChatCmplStreamHandler.handle_stream(
        _empty_response(), cast(Any, _stream(*chunks)), model="claude-sonnet-4-5")]
    announced = [(e.output_index, _key(e.item)) for e in events
                 if e.type == "response.output_item.added"]
    completed = cast(ResponseCompletedEvent, events[-1])
    final = [(i, _key(item)) for i, item in enumerate(completed.response.output)]
    problems = []
    if len(announced) != len(final):
        problems.append(f"#added={len(announced)} != #output={len(final)}")
    if sorted(announced) != sorted(final):
        problems.append(f"announced={announced} final={final}")
    print(f"order={order}: added={announced} final={final}"
          f"{('  <-- ' + '; '.join(problems)) if problems else '  OK'}")


async def main() -> None:
    for order in itertools.permutations(["text", "tool", "reasoning"]):
        await check(order)


asyncio.run(main())

Actual output

Verbatim from a run on pristine main at fbf59a40e9da5adb88d370fefaeaae0478376d4a (uv run python probe_matrix.py):

order=('text', 'tool', 'reasoning'): added=[(0, 'text'), (1, 'tool'), (0, 'reasoning')] final=[(0, 'reasoning'), (1, 'text'), (2, 'tool')]   <-- announced=[(0, 'text'), (1, 'tool'), (0, 'reasoning')] final=[(0, 'reasoning'), (1, 'text'), (2, 'tool')]
order=('text', 'reasoning', 'tool'): added=[(0, 'text'), (0, 'reasoning'), (2, 'tool')] final=[(0, 'reasoning'), (1, 'text'), (2, 'tool')]   <-- announced=[(0, 'text'), (0, 'reasoning'), (2, 'tool')] final=[(0, 'reasoning'), (1, 'text'), (2, 'tool')]
order=('tool', 'text', 'reasoning'): added=[(0, 'tool'), (1, 'text'), (0, 'reasoning')] final=[(0, 'reasoning'), (1, 'text'), (2, 'tool')]   <-- announced=[(0, 'tool'), (1, 'text'), (0, 'reasoning')] final=[(0, 'reasoning'), (1, 'text'), (2, 'tool')]
order=('tool', 'reasoning', 'text'): added=[(0, 'tool'), (0, 'reasoning'), (2, 'text')] final=[(0, 'reasoning'), (1, 'tool'), (2, 'text')]   <-- announced=[(0, 'tool'), (0, 'reasoning'), (2, 'text')] final=[(0, 'reasoning'), (1, 'tool'), (2, 'text')]
order=('reasoning', 'text', 'tool'): added=[(0, 'reasoning'), (1, 'text'), (2, 'tool')] final=[(0, 'reasoning'), (1, 'text'), (2, 'tool')]   OK
order=('reasoning', 'tool', 'text'): added=[(0, 'reasoning'), (1, 'tool'), (2, 'text')] final=[(0, 'reasoning'), (1, 'tool'), (2, 'text')]   OK

Two further observations from the same probes, on the multi-tool ordering text, reasoning, tool0, tool1:

announced=[(0, 'text'), (0, 'reasoning'), (2, 'tool'), (3, 'tool')]
final    =[(0, 'reasoning'), (1, 'tool'), (2, 'text'), (3, 'tool')]

Here index 1 is never announced at all, and the tool call that was announced at 2 ends up at list position 1 — ahead of the assistant message — because the negative function_calls_before_message slice moved it. That reordering is visible in response.completed.output itself, not just in event indexes: a function_call is placed before the assistant message it was streamed after.

Expected behavior

For every response.output_item.added event, output_index should equal the index of that item in response.completed.response.output, and each index should be announced exactly once. All six orderings above should print OK.

Basis for that expectation, from this repository:

  • The class's own contract — _StreamOutputLayout docstring, src/agents/models/chatcmpl_stream_handler.py:196: "Tracks output slots that have been exposed to stream consumers."
  • The maintainer's PR fix: #3109 stabilize chat completions stream output indexes #3176 (seratch), which introduced this layout: "the fix centralizes stream output slot allocation in a small internal layout helper ... so final response.completed.response.output order stays aligned with the emitted stream indexes."
  • The sibling implementation src/agents/testing/model.py:841 builds its streamed items with for output_index, output_item in enumerate(output), i.e. output_index is exactly the item's position in the completed output list.
  • Existing tests assert the same invariant: test_fallback_function_call_before_text_uses_final_output_index and the content_index matches its position in the completed response assertion in tests/models/test_openai_chatcompletions_stream.py (e.g. :3138, :3341-3346, :3555), and tests/models/test_reasoning_content.py:486 (assert event.output_index == 1, the message's real position).

The Responses streaming protocol makes output_index the item's slot in the final response.output; a consumer that replays the stream into a response object (as this SDK's own testing model does) gets a different ordering than the one announced.

Root cause

src/agents/models/chatcmpl_stream_handler.py:205 (_StreamOutputLayout.assistant_message_output_index, with _reasoning_output_count at :202 and function_calls_before_message at :255).

The layout latches a slot using a count that only reflects whether a reasoning item exists at latch time, while the reasoning item is created lazily mid-stream (state.reasoning_content_index_and_output is assigned with the hardcoded slot 0 at lines 726, 749 and 805). Any reasoning item that appears after the first text or tool-call chunk therefore reuses slot 0, and because _reasoning_output_count then flips from 0 to 1, the derived assistant_message_output_idx - _reasoning_output_count(state) becomes -1; the [:count] slice at line 255 silently degrades to "all but the last" instead of raising. The final assembly at line 1291 appends the reasoning item first unconditionally, which is what makes the completed payload disagree with the announced indexes.

Approach I would take: allocate the reasoning item's index from _StreamOutputLayout at creation time (a latched reasoning_output_index(state)) rather than hardcoding 0 in the added/done/summary events (lines 726/735, 749, 805, 507-512, 575-604) and in the final assembly, and additionally clamp function_calls_before_message / function_calls_after_message with a non-negative count so a stale index can never produce a silent negative slice.

I'm happy to open a PR with that approach; tell me if you'd rather have it assigned or want a different shape for the fix.

Related: #3109 (closed) and #3176 (merged) stabilized the assistant-message/tool-call slots; #4275 (merged) addressed the assistant announcement; #3757 (closed) touched content_index offsetting. None of them cover a reasoning item created after other output. No open issue or PR in this repository matches this report, and no open PR currently touches src/agents/models/chatcmpl_stream_handler.py.

Activity

  1. rygelouv commented on Sep 15, 2026

    @rygelouv

    I reproduced this locally on clean main at fbf59a4, macOS, Python 3.13. Your probe_matrix.py gives me byte-identical output — same four broken orderings, same two passing.

    I also traced the mechanism to be sure it's what you describe. It is. The negative slice is what actually moves items around:

    text streams first  -> assistant slot latches at 0, _reasoning_output_count() == 0
    reasoning appears   -> count flips 0 -> 1, slot already latched
                        -> 0 - 1 == -1
                        -> list(state.function_calls.values())[:-1] == ['alpha']
    

    So with two calls, alpha gets treated as "before the message" even though it streamed after.

    I found one case you didn't list. Reasoning arriving last breaks the same way:

    order: text, tool0, tool1, reasoning
      announced : [(0, 'text'), (1, 'tool'), (2, 'tool'), (0, 'reasoning')]
      final     : [(0, 'reasoning'), (1, 'tool'), (2, 'text'), (3, 'tool')]
      duplicate announced index : 0
      never-announced index     : 3
    

    Index 3 never gets announced, and index 0 gets announced twice. Probably worth a test case.

    I've got a fix working, along the lines you suggested. All six orderings pass now, plus the multi-tool ones.

    The clamp and the latched slot aren't enough on their own, though. Once reasoning can hold a slot other than 0, the slot arithmetic for the assistant message and for function calls both need to account for it too, or they collide with reasoning's new slot. And the final assembly has to stop putting reasoning first unconditionally.

    I also had to be careful with test_mixed_function_calls_before_text_keep_tracked_order — a fallback tool call is tracked early but announced last, and it has to keep its tracked position. My first attempt allocated slots purely in announcement order and broke that test.

    You said you'd be happy to open a PR. Do you want to take it? Happy to hand over what I have — I'd rather not duplicate your work. Otherwise I'll put one up shortly.

  2. seratch commented on Sep 17, 2026

    @seratch
    Member

    Closing this for the reason I mentioned at #5042 (comment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions