# NousResearch/hermes-agent · Stale reasoning reuse and extraction fixes

## Open now

- 0 issues, 1 PR open on GitHub.
- PR #25404: fix: agent loop stability — stale reasoning, vision API key, TUI prompt

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.

## TL;DR

- 5 member(s): 0 open issue(s), 2 closed issue(s), 1 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #20174. Verify it addresses your case.
- **Recommended:** read PR #20174 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member 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.

## Closed / duplicate issues

- #17052: Bug: stale reasoning reused when current turn has no reasoning_content
- #17500: \[Bug\]: Gateway can reuse a stale reasoning block from a previous assistant turn

## Prior & competing PRs

- **#20174: fix: scope reasoning-box extraction to the current turn (salvage #17055)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `run_agent.py`, `scripts/release.py`, `tests/cli/test_reasoning_command.py`, `tests/run_agent/test_last_reasoning_per_turn.py`
  - fix hunks:
  - `run_agent.py`

    ```diff
    @@ -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`

    ```diff
    @@ -59,6 +59,7 @@
         "[redacted:email]": "TheEpTic",
         "74554762+wmagev@users.noreply.github.com": "wmagev",
         "[redacted:email]": "ashermorse",
    +    "happy5318@users.noreply.github.com": "happy5318",
         "[redacted:email]": "EmelyanenkoK",
         # Matrix parity salvage batch (April 2026)
         "sr@samirusani": "samrusani",
    ```
  - `tests/cli/test_reasoning_command.py`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```
- **#17055: fix: prevent stale reasoning from being reused across turns**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#20174`
- **#25404: fix: agent loop stability — stale reasoning, vision API key, TUI prompt**. open
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `efe1cb00c88234ab4c81055a8aac07689a315508`, `458a94e42568b332e8794ca8fbb8c8e1279160a3`
  - files: `agent/auxiliary_client.py`, `tests/agent/test_auxiliary_client.py`, `tests/agent/test_auxiliary_main_first.py`

## Fix status

- Merged to main in PR #20174 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-05`.
- Subsystem: `agent`
