NousResearch/hermes-agent

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

0open issues
1open PR
5items grouped
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

No open issues.

Open PRs1

  • PR#25404fix: agent loop stability — stale reasoning, vision API key, TUI prompt

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #20174 fix: scope reasoning-box extraction to the current turn (salvage #17055)

    merged to main

    files: run_agent.py, scripts/release.py, tests/cli/test_reasoning_command.py, tests/run_agent/test_last_reasoning_per_turn.py

    Show 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
  • Closed#17052Bug: stale reasoning reused when current turn has no reasoning_content
  • Closed#17500[Bug]: Gateway can reuse a stale reasoning block from a previous assistant turn
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.00 means 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 Lab

At a glance

Subsystem
agent
Merged PRs
#20174
Closed issues
2
Attempts
1 PR closed without merging
Fix link checked
5 May 2026

For your agent

The same facts, typed.