OpenAIResponsesChatGenerator keeps only the last reasoning item when a response contains several · Issue #13361 · deepset-ai/haystack · GitHub
Skip to content

OpenAIResponsesChatGenerator keeps only the last reasoning item when a response contains several #13361

Open

Description

@pcbeingused333

Describe the bug

A Responses API output can contain more than one reasoning item, for example when the model reasons before each of several function calls. _convert_response_to_chat_message in haystack/components/generators/chat/openai_responses.py assigns the reasoning variable on every reasoning item, so only the last one reaches the ChatMessage. The earlier items, including their id and any encrypted_content, are dropped. ChatMessage.from_assistant accepts a single reasoning, although ChatMessage.reasonings is already a list.

To Reproduce

from openai.types.responses import Response
from haystack.components.generators.chat.openai_responses import _convert_response_to_chat_message

response = Response.model_validate({
    "id": "r", "created_at": 0, "model": "m", "object": "response",
    "parallel_tool_calls": True, "tool_choice": "auto", "tools": [],
    "output": [
        {"type": "reasoning", "id": "rs_1", "summary": [{"type": "summary_text", "text": "first"}]},
        {"type": "function_call", "id": "fc_1", "call_id": "c1", "name": "a", "arguments": "{}"},
        {"type": "reasoning", "id": "rs_2", "summary": [{"type": "summary_text", "text": "second"}]},
        {"type": "function_call", "id": "fc_2", "call_id": "c2", "name": "b", "arguments": "{}"},
    ],
})
message = _convert_response_to_chat_message(response)
print(len(message.reasonings), [r.extra.get("id") for r in message.reasonings])

Actual output:

1 ['rs_2']

Expected: both rs_1 and rs_2 are available on the message.

Why it matters

When the message is sent back (_convert_chat_message_to_responses_api_format already iterates over message.reasonings), the Responses API expects each function call to be accompanied by its reasoning item, and with include=["reasoning.encrypted_content"] the encrypted content of rs_1 is lost.

The streaming path (_convert_streaming_chunks_to_chat_message) has the same shape: all reasoning chunks are folded into one ReasoningContent (last id, concatenated text, merged extra), so the items cannot be told apart.

Proposed direction

Two options, happy to follow whichever fits the design better:

  1. Let ChatMessage.from_assistant accept a list of ReasoningContent and keep one per reasoning item in both the streaming and non-streaming paths.
  2. Keep a single ReasoningContent and concatenate the text, accepting that per-item ids are lost.

Which would the maintainers prefer? A PR can follow once the direction is clear.

Activity

  1. its-amann commented on Oct 21, 2026

    @its-amann

    @pcbeingused333, thanks for the repro. I'm working on preserving one ReasoningContent per Responses API reasoning item, including its ID and encrypted content, in both the normal and streaming paths.

    The approach is to let the existing ChatMessage.from_assistant(reasoning=...) parameter also accept a list, keeping the current string and single-item forms compatible. Streaming chunks would be grouped by output item instead of merging metadata across items. I'll add regression tests for multiple reasoning items, encrypted-only items, and conversion back to API input.

    @anakin87, please let me know if this is already being handled internally or if you prefer a different API shape.

  2. anakin87 commented on Oct 21, 2026

    @anakin87
    Member

    Hello!

    Before discussing a solution, I'd like to see a reproducible end-to-end example using actual OpenAIResponsesChatGenerator executions, without mocks or manually constructed responses.

  3. its-amann commented on Oct 21, 2026

    @its-amann

    Thanks @anakin87, that makes sense. The unit cases confirm that conversion drops items, but they don't establish that a live provider returns that shape.

    @pcbeingused333, could you share a minimal example using actual OpenAIResponsesChatGenerator.run() calls, along with the model/provider, relevant generation parameters, and SDK version? A redacted response showing the separate reasoning items would also help. Please leave out API keys and encrypted content.

    I'll hold off on opening a PR until we can reproduce the behavior end to end.

  4. its-amann commented on Oct 21, 2026

    @its-amann

    @anakin87, I was able to reproduce this with actual OpenAIResponsesChatGenerator.run() calls against OpenAI, without mocks or constructed SDK responses.

    Tested with gpt-5-mini, OpenAI Python SDK 3.35.1, and Haystack main at 95ee53a:

    from haystack.components.generators.chat import OpenAIResponsesChatGenerator
    from haystack.dataclasses import ChatMessage
    
    # Set OPENAI_API_KEY in the environment. This makes paid API requests.
    stream = False  # Repeat with True to check streaming.
    chunks = []
    generator = OpenAIResponsesChatGenerator(
        model="gpt-5-mini",
        timeout=45,
        max_retries=0,
        tools=[
            {"type": "web_search", "search_context_size": "low"},
            {
                "type": "function",
                "name": "multiply",
                "description": "Multiply two integers exactly.",
                "parameters": {
                    "type": "object",
                    "properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
                    "required": ["a", "b"],
                    "additionalProperties": False,
                },
                "strict": True,
            },
        ],
        generation_kwargs={
            "max_output_tokens": 2048,
            "max_tool_calls": 1,
            "parallel_tool_calls": True,
            "reasoning": {"effort": "low", "summary": "auto"},
            "include": ["reasoning.encrypted_content"],
            "store": False,
        },
    )
    reply = generator.run(
        [ChatMessage.from_user(
            "Use web_search to find the year Python 3.0 was first released on python.org. "
            "Then use multiply to multiply that year by 7. Do the search before the multiplication tool call. "
            "Do not compute the product yourself."
        )],
        streaming_callback=chunks.append if stream else None,
    )["replies"][0]
    print(len(reply.reasonings), [r.extra.get("id") for r in reply.reasonings])

    In both modes the actual API output was reasoning, web_search_call, reasoning, function_call, with distinct reasoning IDs. On main, the message contained only the second ID. The streaming path also combined the two summaries into that single item.

    On the fix branch, both IDs survive in order and are included when the message is converted back to API input. I checked the encrypted payloads too: each non-streaming item and each streaming output_item.done payload is retained separately. Follow-up run() calls with the multiplication result completed successfully in both patched modes.

    For clarity, the follow-up also completed on main in the non-streaming check. This reproduces loss of earlier reasoning items; it does not demonstrate an API rejection. The live example uses a built-in search followed by a function call, rather than the issue's constructed example with two function calls.

    The branch also passes 388 focused unit tests, full type checking, applicable pre-commit hooks, and package builds. @pcbeingused333, does this match the scenario you had in mind?

  5. pcbeingused333 commented on Oct 21, 2026

    @pcbeingused333
    ContributorAuthor

    Thanks @its-amann, yes, that is the scenario: a single Responses API output with more than one reasoning item (one before each function call), where the converted ChatMessage only kept the last one. Glad it reproduces with live calls, and thanks for picking up the fix.

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

Metadata

Metadata

Assignees

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