# NousResearch/hermes-agent · Hiding <think> reasoning tags in /resume recap

## Open now

- Nothing open on GitHub.

These members center on /resume recaps potentially exposing hidden reasoning wrapped in <think> tags, with one issue report and several pull requests proposing to hide or strip such tags. One PR describes a broader scope that also covers storage and API replay.

## TL;DR

- 4 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #12361. Verify it addresses your case.
- **Recommended:** read PR #12361 (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_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.50`: 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

- #11316: \[Bug\]: /resume recap can leak hidden reasoning blocks wrapped in <think> tags

## Prior & competing PRs

- **#11366: fix(cli): strip <think> and other reasoning tags from /resume recap**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#12361`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#12361: fix: eliminate <think> leakage across storage, API replay, and /resume**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `cli.py`, `run_agent.py`, `tests/cli/test_resume_display.py`, `tests/run_agent/test_run_agent.py`
  - fix hunks:
  - `cli.py`

    ```diff
    @@ -83,17 +83,51 @@
     _REASONING_TAGS = (
         "REASONING_SCRATCHPAD",
         "think",
    -    "reasoning",
    -    "THINKING",
         "thinking",
    +    "reasoning",
    +    "thought",
     )
     
     
     def _strip_reasoning_tags(text: str) -> str:
    +    """Remove reasoning/thinking blocks from displayed text.
    +
    +    Handles every case:
    +      * Closed pairs ``<tag>…</tag>`` (case-insensitive, multi-line).
    +      * Unterminated open tags that run to end-of-text (e.g. truncated
    +        generations on NIM/MiniMax where the close tag is dropped).
    +      * Stray orphan close tags (``stuff</think>answer``) left behind by
    +        partial-content dumps.
    +
    +    Covers the variants emitted by reasoning models today: ``<think>``,
    +    ``<thinking>``, ``<reasoning>``, ``<REASONING_SCRATCHPAD>``, and
    +    ``<thought>`` (Gemma 4).  Must stay in sync with
    +    ``run_agent.py::_strip_think_blocks`` and the stream consumer's
    +    ``_OPEN_THINK_TAGS`` / ``_CLOSE_THINK_TAGS`` tuples.
    +    """
         cleaned = text
         for tag in _REASONING_TAGS:
    -        cleaned = re.sub(rf"<{tag}>.*?</{tag}>\s*", "", cleaned, flags=re.DOTALL)
    -        cleaned = re.sub(rf"<{tag}>.*$", "", cleaned, flags=re.DOTALL)
    +        # Closed pair — case-insensitive so <THINK>…</THINK> is handled too.
    +        cleaned = re.sub(
    +            rf"<{tag}>.*?</{tag}>\s*",
    +            "",
    +            cleaned,
    +            flags=re.DOTALL | re.IGNORECASE,
    +        )
    +        # Unterminated open tag — strip from the tag to end of text.
    +        cleaned = re.sub(
    +            rf"<{tag}>.*$",
    +            "",
    +            cleaned,
    +            flags=re.DOTALL | re.IGNORECASE,
    +        )
    +        # Stray orphan close tag left behind by partial dumps.
    +        cleaned = re.sub(
    +            rf"</{tag}>\s*",
    +            "",
    +            cleaned,
    +            flags=re.IGNORECASE,
    +        )
         return cleaned.strip()
     
     
    ```
  - `run_agent.py`

    ```diff
    @@ -2172,17 +2172,49 @@ def _has_content_after_think_block(self, content: str) -> bool:
             return bool(cleaned.strip())
         
         def _strip_think_blocks(self, content: str) -> str:
    -        """Remove reasoning/thinking blocks from content, returning only visible text."""
    +        """Remove reasoning/thinking blocks from content, returning only visible text.
    +
    +        Handles four cases:
    +          1. Closed tag pairs (``<think>…</think>``) — the common path when
    +             the provider emits complete reasoning blocks.
    +          2. Unterminated open tag at a block boundary (start of text or
    +             after a newline) — e.g. MiniMax M2.7 / NIM endpoints where the
    +             closing tag is dropped.  Everything from the open tag to end
    +             of string is stripped.  The block-boundary check mirrors
    +             ``gateway/stream_consumer.py``'s filter so models that mention
    +             ``<think>`` in prose aren't over-stripped.
    +          3. Stray orphan open/close tags that slip through.
    +          4. Tag variants: ``<think>``, ``<thinking>``, ``<reasoning>``,
    +             ``<REASONING_SCRATCHPAD>``, ``<thought>`` (Gemma 4), all
    +             case-insensitive.
    +        """
             if not content:
                 return ""
    -        # Strip all reasoning tag variants: <think>, <thinking>, <THINKING>,
    -        # <reasoning>, <REASONING_SCRATCHPAD>, <thought> (Gemma 4)
    -        content = re.sub(r'<think>.*?</think>', '', content, flags=re.DOTALL)
    +        # 1. Closed tag pairs — case-insensitive for all variants so
    +        #    mixed-case tags (<THINK>, <Thinking>) don't slip through to
    +        #    the unterminated-tag pass and take trailing content with them.
    +        content = re.sub(r'<think>.*?</think>', '', content, flags=re.DOTALL | re.IGNORECASE)
             content = re.sub(r'<thinking>.*?</thinking>', '', content, flags=re.DOTALL | re.IGNORECASE)
    -        content = re.sub(r'<reasoning>.*?</reasoning>', '', content, flags=re.DOTALL)
    -        content = re.sub(r'<REASONING_SCRATCHPAD>.*?</REASONING_SCRATCHPAD>', '', content, flags=re.DOTALL)
    +        content = re.sub(r'<reasoning>.*?</reasoning>', '', content, flags=re.DOTALL | re.IGNORECASE)
    +        content = re.sub(r'<REASONING_SCRATCHPAD>.*?</REASONING_SCRATCHPAD>', '', content, flags=re.DOTALL | re.IGNORECASE)
             content = re.sub(r'<thought>.*?</thought>', '', content, flags=re.DOTALL | re.IGNORECASE)
    -        content = re.sub(r'</?(?:think|thinking|reasoning|thought|REASONING_SCRATCHPAD)>\s*', '', content, flags=re.IGNORECASE)
    +        # 2. Unterminated reasoning block — open tag at a block boundary
    +        #    (start of text, or after a newline) with no matching close.
    +        #    Strip from the tag to end of string.  Fixes #8878 / #9568
    +        #    (MiniMax M2.7 leaking raw reasoning into assistant content).
    +        content = re.sub(
    +            r'(?:^|\n)[ \t]*<(?:think|thinking|reasoning|thought|REASONING_SCRATCHPAD)\b[^>]*>.*$',
    +            '',
    +            content,
    +            flags=re.DOTALL | re.IGNORECASE,
    +        )
    +        # 3. Stray orphan open/close tags that slipped through.
    +        content = re.sub(
    +            r'</?(?:think|thinking|reasoning|thought|REASONING_SCRATCHPAD)>\s*',
    +            '',
    +            content,
    +            flags=re.IGNORECASE,
    +        )
             return content
     
         @staticmethod
    ```

    ```diff
    @@ -7262,6 +7294,20 @@ def _build_assistant_message(self, assistant_message, finish_reason: str) -> dic
             if reasoning_text:
                 reasoning_text = _sanitize_surrogates(reasoning_text)
     
    +        # Strip inline reasoning tags (<think>…</think> etc.) from the stored
    +        # assistant content.  Reasoning was already captured into
    +        # ``reasoning_text`` above (either from structured fields or the
    +        # inline-block fallback), so the raw tags in content are redundant.
    +        # Leaving them in place caused reasoning to leak to messaging
    +        # platforms (#8878, #9568), inflate context on subsequent turns
    +        # (#9306 observed 16% content-size reduction on a real MiniMax
    +        # session), and pollute generated session titles.  One strip at the
    +        # storage boundary cleans content for every downstream consumer:
    +        # API replay, session transcript, gateway delivery, CLI display,
    +        # compression, title generation.
    +        if isinstance(_san_content, str) and _san_content:
    +            _san_content = self._strip_think_blocks(_san_content).strip()
    +
             msg = {
                 "role": "assistant",
                 "content": _san_content,
    ```
  - `tests/cli/test_resume_display.py`

    ```diff
    @@ -344,6 +344,127 @@ def test_pure_reasoning_message_skipped(self):
             assert "Just thinking" not in output
             assert "Hi there!" in output
     
    +    def test_think_tags_stripped(self):
    +        """<think>...</think> blocks should be stripped from display (#11316)."""
    +        cli = _make_cli()
    +        cli.conversation_history = [
    +            {"role": "user", "content": "Solve this"},
    +            {
    +                "role": "assistant",
    +                "content": "<think>\nI need to reason carefully here.\n</think>\n\nThe answer is 7.",
    +            },
    +        ]
    +        output = self._capture_display(cli)
    +
    +        assert "<think>" not in output
    +        assert "</think>" not in output
    +        assert "I need to reason carefully here" not in output
    +        assert "The answer is 7" in output
    +
    +    def test_thinking_tags_stripped(self):
    +        """<thinking>...</thinking> blocks should be stripped from display."""
    +        cli = _make_cli()
    +        cli.conversation_history = [
    +            {"role": "user", "content": "What is 2+2?"},
    +            {
    +                "role": "assistant",
    +                "content": "<thinking>\nLet me compute: 2 + 2 = 4\n</thinking>\n\nThe answer is 4.",
    +            },
    +        ]
    +        output = self._capture_display(cli)
    +
    +        assert "<thinking>" not in output
    +        assert "Let me compute" not in output
    +        assert "The answer is 4" in output
    +
    +    def test_reasoning_tags_stripped(self):
    +        """<reasoning>...</reasoning> blocks should be stripped from display."""
    +        cli = _make_cli()
    +        cli.conversation_history = [
    +            {"role": "user", "content": "Explain gravity"},
    +            {
    +                "role": "assistant",
    +                "content": (
    +                    "<reasoning>\nGravity is a fundamental force...\n</reasoning>\n\n"
    +                    "Gravity pulls objects together."
    +                ),
    +            },
    +        ]
    +        output = self._capture_display(cli)
    +
    +        assert "<reasoning>" not in output
    +        assert "fundamental force" not in output
    +        assert "Gravity pulls objects together" in output
    +
    +    def test_thought_tags_stripped(self):
    +        """<thought>...</thought> blocks (Gemma 4) should be stripped."""
    +        cli = _make_cli()
    +        cli.conversation_history = [
    +            {"role": "user", "content": "Say hello"},
    +            {
    +                "role": "assistant",
    +                "content": "<thought>\nInternal thought here.\n</thought>\n\nHello!",
    +            },
    +        ]
    +        output = self._capture_display(cli)
    +
    +        assert "<thought>" not in output
    +        assert "Internal thought here" not in output
    +        assert "Hello!" in output
    +
    +    def test_unclosed_think_tag_stripped(self):
    +        """Unclosed <think> (truncated generation) should not leak reasoning."""
    +        cli = _make_cli()
    +        cli.conversation_history = [
    +            {"role": "user", "content": "Truncated response"},
    +            {
    +                "role": "assistant",
    +                "content": "Some text before.\n<think>\nUnfinished reasoning...",
    +            },
    +        ]
    +        output = self._capture_display(cli)
    +
    +        assert "<think>" not in output
    +        assert "Unfinished reasoning" not in output
    +        assert "Some text before" in output
    +
    +    def test_multiple_reasoning_blocks_all_stripped(self):
    +        """Multiple interleaved reasoning blocks are all stripped."""
    +        cli = _make_cli()
    +        cli.conversation_history = [
    +            {"role": "user", "content": "Complex question"},
    +            {
    +                "role": "assistant",
    +                "content": (
    +                    "<think>\nFirst thought.\n</think>\n"
    +                    "Partial text.\n"
    +                    "<reasoning>\nSecond thought.\n</reasoning>\n"
    +                    "Final answer."
    +                ),
    +            },
    +        ]
    +        output = self._capture_display(cli)
    +
    +        assert "First thought" not in output
    +        assert "Second thought" not in output
    +        assert "Partial text" in output
    +        assert "Final answer" in output
    +
    +    def test_orphan_closing_think_tag_stripped(self):
    +        """A stray </think> with no matching open should not render to user."""
    +        cli = _make_cli()
    +        cli.conversation_history = [
    +            {"role": "user", "content": "Broken output"},
    +            {
    +                "role": "assistant",
    +                "content": "some leftover reasoning</think>Visible answer.",
    +            },
    +        ]
    +        output = self._capture_display(cli)
    +
    +        assert "</think>" not in output
    +        assert "Visible answer" in output
    +
         def test_assistant_with_text_and_tool_calls(self):
             """When an assistant message has both text content AND tool_calls."""
             cli = _make_cli()
    ```
  - `tests/run_agent/test_run_agent.py`

    ```diff
    @@ -317,6 +317,60 @@ def test_orphaned_thought_tag(self, agent):
             result = agent._strip_think_blocks("<thought>orphaned reasoning without close")
             assert "<thought>" not in result
     
    +    # ─── Unterminated-block coverage (#8878, #9568, #10408) ──────────────
    +    # Reasoning models served via NIM / MiniMax M2.7 frequently drop the
    +    # closing tag, leaking raw reasoning into assistant content. The open
    +    # tag appears at a block boundary (start of text or after a newline);
    +    # everything from that tag to end-of-string is stripped.
    +
    +    def test_unterminated_think_block_content_stripped(self, agent):
    +        """Content after unterminated <think> is fully stripped."""
    +        result = agent._strip_think_blocks("<think>orphaned reasoning without close")
    +        assert "orphaned reasoning" not in result
    +        assert result.strip() == ""
    +
    +    def test_unterminated_thought_block_content_stripped(self, agent):
    +        """Gemma-style <thought> with no close is fully stripped."""
    +        result = agent._strip_think_blocks("<thought>orphaned reasoning without close")
    +        assert "orphaned reasoning" not in result
    +        assert result.strip() == ""
    +
    +    def test_unterminated_multiline_block_stripped(self, agent):
    +        """Multi-line unterminated blocks are stripped in full."""
    +        result = agent._strip_think_blocks(
    +            "<think>\nmulti\nline\nreasoning\nthat never closes"
    +        )
    +        assert "multi" not in result
    +        assert "never closes" not in result
    +
    +    def test_unterminated_block_after_answer_preserves_prefix(self, agent):
    +        """Visible answer before a line-starting unterminated tag is kept."""
    +        result = agent._strip_think_blocks(
    +            "Answer is 42.\n<think>actually let me reconsider"
    +        )
    +        assert "Answer is 42." in result
    +        assert "reconsider" not in result
    +
    +    def test_inline_think_mention_in_prose_not_over_stripped(self, agent):
    +        """Mid-line `<think>` mentioned in prose must not swallow the rest
    +        of the content (the block-boundary check prevents this)."""
    +        text = "Use the <think> tag like this in your prose."
    +        result = agent._strip_think_blocks(text)
    +        # Block-boundary check prevents unterminated-strip from firing
    +        assert "prose" in result
    +        assert "Use the" in result
    +
    +    def test_mixed_case_closed_pair_stripped(self, agent):
    +        """Mixed-case variants <THINK>…</THINK>, <Thinking>…</Thinking> are
    +        handled by case-insensitive closed-pair regex, so the trailing
    +        content is preserved."""
    +        result = agent._strip_think_blocks("<THINK>upper</THINK>final")
    +        assert "upper" not in result
    +        assert "final" in result
    +        result = agent._strip_think_blocks("<Thinking>mixed</Thinking>final")
    +        assert "mixed" not in result
    +        assert "final" in result
    +
     
     class TestExtractReasoning:
         def test_reasoning_field(self, agent):
    ```

    ```diff
    @@ -1088,6 +1142,41 @@ def test_tool_call_without_extra_content(self, agent):
             result = agent._build_assistant_message(msg, "tool_calls")
             assert "extra_content" not in result["tool_calls"][0]
     
    +    def test_think_blocks_stripped_from_content(self, agent):
    +        """Inline <think> blocks are stripped from stored content (#8878, #9568).
    +
    +        The reasoning is captured into ``msg['reasoning']`` via the inline
    +        fallback in ``_extract_reasoning``; the raw tags in ``content`` are
    +        redundant and leak to messaging platforms / pollute titles /
    +        inflate context if left in place.
    +        """
    +        msg = _mock_assistant_msg(
    +            content="<think>internal reasoning</think>The actual answer."
    +        )
    +        result = agent._build_assistant_message(msg, "stop")
    +        assert "<think>" not in result["content"]
    +        assert "internal reasoning" not in result["content"]
    +        assert "The actual answer." in result["content"]
    +        # Reasoning preserved separately via inline extraction fallback
    +        assert result["reasoning"] == "internal reasoning"
    +
    +    def test_think_blocks_stripped_preserves_normal_content(self, agent):
    +        """Content without reasoning tags passes through unchanged."""
    +        msg = _mock_assistant_msg(content="No thinking here.")
    +        result = agent._build_assistant_message(msg, "stop")
    +        assert result["content"] == "No thinking here."
    +
    +    def test_unterminated_think_block_stripped(self, agent):
    +        """Unterminated <think> block (MiniMax / NIM dropped close tag) is
    +        fully stripped from stored content."""
    +        msg = _mock_assistant_msg(
    +            content="<think>reasoning that never closes on this NIM endpoint"
    +        )
    +        result = agent._build_assistant_message(msg, "stop")
    +        assert "<think>" not in result["content"]
    +        assert "reasoning that never closes" not in result["content"]
    +        assert result["content"] == ""
    +
     
     class TestFormatToolsForSystemMessage:
         def test_no_tools_returns_empty_array(self, agent):
    ```
- **#11334: fix(cli): hide think tags in resume recap**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `bd01ec788`, `#12361`, `#11316`, `#11366`

## Fix status

- Merged to main in PR #12361 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-19`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
