Visitar URL original
OpenAIResponsesLlm drops reasoning items between tool calls (never replayed or chained) · Issue #8110 · google/adk-python · GitHub
Skip to content

OpenAIResponsesLlm drops reasoning items between tool calls (never replayed or chained) #8110

Description

@QueryPlanner

🔴 Required Information

Describe the Bug:

OpenAIResponsesLlm never sends a model's reasoning back to OpenAI. As a result, in every tool loop the model starts its reasoning again from scratch after each tool call.

Two things combine to cause this:

  1. Reasoning parts are skipped when history is rebuilt. _content_to_response_input_items calls _skip_replayed_reasoning_part for every thought part (call site). Its docstring says the thought parts don't carry reasoning item IDs, and that continuity comes from previous_response_id.
  2. previous_response_id is never set for this model. _get_response_create_kwargs uses llm_request.previous_interaction_id (L1187). The flow fills that field only for Gemini with use_interactions_api (_interactions.py L123). It is also unusable with store=False.

So the reasoning is neither replayed nor chained. Nothing reports this: the API accepts the request without the reasoning items.

The data needed for a fix is already captured. _reasoning_parts puts encrypted_content in each thought part's thought_signature. It also records the reasoning item id (L623), but only in the response-level custom_metadata['openai_response']['reasoning'] (L698). The id is not stored on the part, so _content_to_response_input_items can't rebuild the item.

Why it matters: OpenAI's function calling guide says reasoning items "must also be passed back with tool call outputs". Its reasoning guide describes stateless use: with store: false, pass back all output items in their original order, and reasoning items carry encrypted_content.

Steps to Reproduce:

  1. Install google-adk[openai]==2.14.0. I also checked main, which still skips reasoning parts at the same place.
  2. Run the contributing/samples/models/openai_responses_reasoning agent with OPENAI_MODEL=gpt-6.1-terra, with a prompt that makes the model reason and call tools across several steps.
  3. Subclass OpenAIResponsesLlm and log the type of every item in kwargs["input"] from _get_response_create_kwargs. Also log custom_metadata["openai_response"]["reasoning"] on each response.
class LoggingResponsesLlm(OpenAIResponsesLlm):
    def _get_response_create_kwargs(self, llm_request, *, stream):
        kwargs = super()._get_response_create_kwargs(llm_request, stream=stream)
        print([i.get("type", "message") for i in kwargs["input"]],
              kwargs.get("previous_response_id"))
        return kwargs

Expected Behavior:

Each request after a tool call includes the earlier reasoning items ({type, id, summary, encrypted_content}) in their original positions, before the function_call items they came with. Alternatively, it chains with previous_response_id when store is enabled.

Observed Behavior:

Every response returned one reasoning item with id and encrypted_content, but no later request contained a reasoning item, and previous_response_id was always None:

call 1 input: [message]
call 2 input: [message, function_call x3, function_call_output x3]          # no reasoning
call 3 input: [... , function_call, function_call_output]                   # no reasoning
call 4 input: [... , message, message]                                       # no reasoning
previous_response_id: None on every call

Environment Details:

  • ADK Library Version: 2.14.0 (also checked main)
  • Desktop OS: macOS
  • Python Version: 3.13

Model Information:

  • Are you using LiteLLM: No (OpenAIResponsesLlm)
  • Which model is being used: gpt-6.1-terra (the bug is in the adapter, not the model)

🟡 Optional Information

A working fix, tested live:

A subclass that overrides _get_response_input and adds each reasoning item back in its original position fixes this. It looks up the item id from the captured custom_metadata by encrypted_content, groups summary parts by item, and writes {"type": "reasoning", "id", "summary": [...], "encrypted_content"}. With store=False, OpenAI accepted every replayed item with no errors (up to 72 reasoning items in one run).

Suggested upstream change:

  • Keep the reasoning item id on each thought part (for example in part_metadata) along with the encrypted_content already in thought_signature.
  • In _content_to_response_input_items, rebuild one reasoning item per original item, unchanged and in order, instead of skipping it.
  • Replay only to the model that produced it. Encrypted reasoning is tied to its model family.

Small A/B comparison (gpt-6.1-terra, effort high, store=False, 3 runs per cell, a unique instruction nonce per run so runs can't share caches):

Task Arm Correct Model calls Input tokens Reasoning tokens
Graph search (13 tool calls) as shipped 3/3 8.7 4,933 180
with replay 3/3 11.7 8,677 243
Order totals (10 tool calls) as shipped 3/3 4 2,666 113
with replay 3/3 4 2,699 115

These short tasks used little reasoning, and replay showed no gain in correctness. It increased input tokens, because replayed items count as input. I'm including this for transparency. The gap is still a correctness issue against OpenAI's documented contract; the quality effect should show on long, reasoning-heavy tool loops, which this sample doesn't cover.

Acceptance criteria (each checkable with a mocked HTTP test):

  • After a response containing a reasoning item followed by a function_call, the next request's input contains that reasoning item (same id, summary and encrypted_content) right before the replayed function_call.
  • A reasoning item with several summary parts is replayed as one item, not one per part.
  • A reasoning item with encrypted_content and no summary is still replayed.
  • The same holds when the response is streamed (StreamingMode.SSE).
  • Reasoning from a different model is not replayed.

Suggested priority / effort: P2 (silent loss of model state, no crash); small to medium effort, in _reasoning_parts, _content_to_response_input_items and the stream accumulator, plus tests.

Related: #8109 (the same gap in LiteLlm for OpenRouter reasoning_details), #8029 (another Responses-adapter feature gap).

How often has this issue occurred?:

  • Always (100%): every multi-step call with a reasoning model.

Activity

  1. surajksharma07 commented on Nov 13, 2026

    @surajksharma07
    Collaborator

    @QueryPlanner. Reproduced this on 2.14.1 with a mocked client: the reasoning item is never sent back after the tool call and previous_response_id stays None.

    A possible workaround is a small OpenAIResponsesLlm subclass. It saves the reasoning id on each thought part (in part_metadata, matched by encrypted_content), then rebuilds one {type, id, summary, encrypted_content} item per reasoning item in _get_response_input, only for the same model. It worked in my non-streaming test, but I haven’t tried SSE yet. Could you check it on your longer tool loops, streaming included?

    Please test it thoroughly first and let us know if it holds up. #8112 already covers this, so please check against it before opening a new PR.

  2. QueryPlanner commented on Nov 13, 2026

    @QueryPlanner
    Author

    Thanks for reproducing. Some live data in case it helps review #8112. In my own adapter (QueryPlanner/blacki#217) I store each Responses output item exactly as returned, in part_metadata, and replay it unchanged for the same model, with store=false and include=["reasoning.encrypted_content"]. Tested live on gpt-6.1-terra at effort high with SSE streaming: a turn with several tool calls, then a second turn after restarting the runner on the same SQLite session database. The reasoning items were accepted when replayed directly before their function calls, including after the restart.

    One suggestion: replaying the whole raw item, rather than rebuilding {type, id, summary, encrypted_content}, also keeps fields like phase on assistant messages, which GPT-6 models emit. A history edit can leave a reasoning item with nothing after it, and OpenAI rejects that, so it's worth dropping such items before sending.

  3. surajksharma07 commented on Nov 14, 2026

    @surajksharma07
    Collaborator

    @QueryPlanner, the live SSE + restart result helps a lot. Went through #8112: it keeps the id and encrypted_content in thought_signature, so streaming and DB restarts should be fine. But it doesn’t drop orphaned reasoning items and has no same-model check, so an edited history or a model switch could still hit a 400.

    It also sends an id-only reasoning item when encrypted_content is missing, which will probably fail with store=False if include isn’t set. And only the first summary segment gets replayed.

    @Bensonluo could you add the orphan drop, a same-model guard and that store=False case, with tests? Replaying the raw item to keep phase is a bigger change, so a separate follow-up might be cleaner.

    @QueryPlanner if you get time, a run of the #8112 branch on one of your longer tool loops after that would be really useful.

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

Metadata

Metadata

Labels

No labels
No labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions