Stale reasoning reuse and extraction fixes
Cluster 17052 · agent. Data as of
1 open item. Fix merged in #20174 on 5 May 2026. Check it covers your case.
The short answer
- Known?
- Yes. 5 related items grouped here, 2 issues already closed.
- Fix event
- A fix was merged to main in PR #20174. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- 0 issues and 1 PR.
Multiple bugs and fixes related to reasoning content being incorrectly reused from previous turns instead of scoped to the current turn. Also includes related fixes for reasoning command output ordering and display.
Open issues0
Open PRs1
- PR#25404fix: agent loop stability — stale reasoning, vision API key, TUI prompt
Fixes on record
-
#20174 fix: scope reasoning-box extraction to the current turn (salvage #17055)
files:
run_agent.py,scripts/release.py,tests/cli/test_reasoning_command.py,tests/run_agent/test_last_reasoning_per_turn.pyShow the diff
-
run_agent.py@@ -13952,9 +13952,19 @@ def _stop_spinner(): except Exception as exc: logger.warning("post_llm_call hook failed: %s", exc) - # Extract reasoning from the last assistant message (if any) + # Extract reasoning from the CURRENT turn only. Walk backwards + # but stop at the user message that started this turn — anything + # earlier is from a prior turn and must not leak into the reasoning + # box (confusing stale display; #17055). Within the current turn + # we still want the *most recent* non-empty reasoning: many + # providers (Claude thinking, DeepSeek v4, Codex Responses) emit + # reasoning on the tool-call step and leave the final-answer step + # with reasoning=None, so picking only the last assistant would + # silently drop legitimate same-turn reasoning. last_reasoning = None for msg in reversed(messages): + if msg.get("role") == "user": + break # turn boundary — don't cross into prior turns if msg.get("role") == "assistant" and msg.get("reasoning"): last_reasoning = msg["reasoning"] break -
scripts/release.py@@ -59,6 +59,7 @@ "[redacted:email]": "TheEpTic", "[email protected]": "wmagev", "[redacted:email]": "ashermorse", + "[email protected]": "happy5318", "[redacted:email]": "EmelyanenkoK", # Matrix parity salvage batch (April 2026) "sr@samirusani": "samrusani", -
tests/cli/test_reasoning_command.py@@ -178,6 +178,8 @@ def test_reasoning_present(self): messages = self._build_messages(reasoning="Let me think...") last_reasoning = None for msg in reversed(messages): + if msg.get("role") == "user": + break if msg.get("role") == "assistant" and msg.get("reasoning"): last_reasoning = msg["reasoning"] break @@ -187,6 +189,8 @@ def test_reasoning_none(self): messages = self._build_messages(reasoning=None) last_reasoning = None for msg in reversed(messages): + if msg.get("role") == "user": + break if msg.get("role") == "assistant" and msg.get("reasoning"): last_reasoning = msg["reasoning"] break @@ -201,6 +205,8 @@ def test_picks_last_assistant(self): ] last_reasoning = None for msg in reversed(messages): + if msg.get("role") == "user": + break if msg.get("role") == "assistant" and msg.get("reasoning"): last_reasoning = msg["reasoning"] break @@ -210,6 +216,8 @@ def test_empty_reasoning_treated_as_none(self): messages = self._build_messages(reasoning="") last_reasoning = None for msg in reversed(messages): + if msg.get("role") == "user": + break if msg.get("role") == "assistant" and msg.get("reasoning"): last_reasoning = msg["reasoning"] break @@ -584,6 +592,8 @@ def test_openrouter_claude_pipeline(self): last_reasoning = None for msg in reversed(messages): + if msg.get("role") == "user": + break if msg.get("role") == "assistant" and msg.get("reasoning"): last_reasoning = msg["reasoning"] break -
tests/run_agent/test_last_reasoning_per_turn.py@@ -0,0 +1,107 @@ +"""Tests for per-turn reasoning extraction in AIAgent.run_conversation. + +Verifies the reasoning field returned to display layers (CLI reasoning box, +gateway reasoning footer, TUI reasoning event) only reflects the CURRENT +turn's reasoning — never leaks from a prior turn — and is picked up +correctly when reasoning is attached to a tool-calling assistant step +rather than the final-answer assistant step. +""" +from __future__ import annotations + + +def _extract_last_reasoning(messages): + """Replica of the extraction loop in run_agent.py (~line 13867). + + Tests pin the loop's behaviour so that refactors can't silently + regress the per-turn semantic. + """ + last_reasoning = None + for msg in reversed(messages): + if msg.get("role") == "user": + break + if msg.get("role") == "assistant" and msg.get("reasoning"): + last_reasoning = msg["reasoning"] + break + return last_reasoning + + +def test_simple_turn_reasoning_present(): + messages = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi", "reasoning": "greeting the user"}, + ] + assert _extract_last_reasoning(messages) == "greeting the user" + + +def test_simple_turn_no_reasoning(): + messages = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi", "reasoning": None}, + ] + assert _extract_last_reasoning(messages) is None + + +def test_tool_call_turn_reasoning_on_tool_call_step(): + """When the model reasons on the tool-call step and the final-answer + step has no reasoning (Claude thinking / DeepSeek v4 / Codex Responses + pattern), the box must show the tool-call-step reasoning, not empty. + """ + messages = [ + {"role": "user", "content": "search the repo for X"}, + { + "role": "assistant", + "content": "", + "reasoning": "I should use search_files", + "tool_calls": [{"id": "c1", "type": "function", + "function": {"name": "search_files", "arguments": "{}"}}], + }, + {"role": "tool", "tool_call_id": "c1", "content": "3 matches"}, + {"role": "assistant", "content": "Found 3 matches", "reasoning": None}, + ] + assert _extract_last_reasoning(messages) == "I should use search_files" + + +def test_no_stale_reasoning_across_turns(): + """The regression the whole change exists for. Prior turn had + reasoning; current turn has none. The reasoning box must NOT show + the prior turn's text. + """ + messages = [ + # prior turn + {"role": "user", "content": "explain quantum tunneling"}, + {"role": "assistant", "content": "It's when...", + "reasoning": "tunneling happens when particles..."}, + # current turn + {"role": "user", "content": "thanks"}, + {"role": "assistant", "content": "You're welcome!", "reasoning": None}, + ] + assert _extract_last_reasoning(messages) is None + + +def test_tool_call_turn_picks_latest_reasoning_within_turn(): + """If BOTH the tool-call step and the final step have reasoning + (uncommon but possible), the final-step reasoning wins — it's the + most recent thought within the current turn. + """ + messages = [ + {"role": "user", "content": "search and summarize"}, + { + "role": "assistant", + "content": "", + "reasoning": "initial plan", + "tool_calls": [{"id": "c1", "type": "function", + "function": {"name": "search_files", "arguments": "{}"}}], + }, + {"role": "tool", "tool_call_id": "c1", "content": "results"}, + {"role": "assistant", "content": "Here's the summary", + "reasoning": "synthesized view of results"}, + ] + assert _extract_last_reasoning(messages) == "synthesized view of results" + + +def test_empty_string_reasoning_treated_as_missing(): + messages = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello", "reasoning": ""}, + ] + assert _extract_last_reasoning(messages) is None
-
1 PR closed without merging
From the close comments: superseded by another PR 1
-
#17055 fix: prevent stale reasoning from being reused across turns · superseded by another PR →
#20174
Closed issues · 2
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.34: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- agent
- Merged PRs
- #20174
- Closed issues
- 2
- Attempts
- 1 PR closed without merging
- Fix link checked
- 5 May 2026