NousResearch/hermes-agent

Hiding <think> reasoning tags in /resume recap

Cluster 11316. Data as of

Nothing open on GitHub. Fix merged in #12361 on 19 Apr 2026. Check it covers your case.

The short answer

0open issues
0open PRs
4items grouped
Known?
Yes. 4 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #12361. 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
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

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

  • #12361 fix: eliminate <think> leakage across storage, API replay, and /resume

    merged to main

    files: cli.py, run_agent.py, tests/cli/test_resume_display.py, tests/run_agent/test_run_agent.py

    Show the diff
    • cli.py
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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):
      @@ -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):

2 PRs closed without merging

From the close comments: done elsewhere 1 · other 1

  • #11334 fix(cli): hide think tags in resume recap · done elsewhere → bd01ec7, #12361, #11316, #11366
  • #11366 fix(cli): strip <think> and other reasoning tags from /resume recap · other → #12361
Closed issues · 1
  • Closed#11316[Bug]: /resume recap can leak hidden reasoning blocks wrapped in <think> tags
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.50: 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

Merged PRs
#12361
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
19 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.