Repository navigation
Description
🔴 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:
- Reasoning parts are skipped when history is rebuilt.
_content_to_response_input_itemscalls_skip_replayed_reasoning_partfor every thought part (call site). Its docstring says the thought parts don't carry reasoning item IDs, and that continuity comes fromprevious_response_id. previous_response_idis never set for this model._get_response_create_kwargsusesllm_request.previous_interaction_id(L1187). The flow fills that field only forGeminiwithuse_interactions_api(_interactions.pyL123). It is also unusable withstore=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:
- Install
google-adk[openai]==2.14.0. I also checkedmain, which still skips reasoning parts at the same place. - Run the
contributing/samples/models/openai_responses_reasoningagent withOPENAI_MODEL=gpt-6.1-terra, with a prompt that makes the model reason and call tools across several steps. - Subclass
OpenAIResponsesLlmand log thetypeof every item inkwargs["input"]from_get_response_create_kwargs. Also logcustom_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 kwargsExpected 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
idon each thought part (for example inpart_metadata) along with theencrypted_contentalready inthought_signature. - In
_content_to_response_input_items, rebuild onereasoningitem 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
reasoningitem followed by afunction_call, the next request'sinputcontains thatreasoningitem (sameid,summaryandencrypted_content) right before the replayedfunction_call. - A reasoning item with several summary parts is replayed as one item, not one per part.
- A reasoning item with
encrypted_contentand 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
surajksharma07 commentedon Nov 13, 2026
@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.
QueryPlanner commentedon Nov 13, 2026
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, withstore=falseandinclude=["reasoning.encrypted_content"]. Tested live ongpt-6.1-terraat efforthighwith 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 likephaseon 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.surajksharma07 commentedon Nov 14, 2026
@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.