{"repo":"NousResearch/hermes-agent","public_id":"11316","display_label":"Hiding <think> reasoning tags in /resume recap","summary":"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.","subsystem":null,"counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":12361,"merged_at":"2026-04-19T02:19:24+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-19T02:19:24+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":9568,"pr_number":12361,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-19T02:19:24+00:00"},{"issue_number":11316,"pr_number":12361,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-19T02:19:24+00:00"}],"members":[{"kind":"issue","number":11316,"title":"[Bug]: /resume recap can leak hidden reasoning blocks wrapped in <think> tags","url":null,"state":"closed"},{"kind":"pr","number":11334,"title":"fix(cli): hide think tags in resume recap","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"bd01ec788","number":0},{"kind":"pr","value":"#12361","number":12361},{"kind":"pr","value":"#11316","number":11316},{"kind":"pr","value":"#11366","number":11366}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":11366,"title":"fix(cli): strip <think> and other reasoning tags from /resume recap","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#12361","number":12361}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":12361,"title":"fix: eliminate <think> leakage across storage, API replay, and /resume","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","run_agent.py","tests/cli/test_resume_display.py","tests/run_agent/test_run_agent.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -83,17 +83,51 @@\n _REASONING_TAGS = (\n     \"REASONING_SCRATCHPAD\",\n     \"think\",\n-    \"reasoning\",\n-    \"THINKING\",\n     \"thinking\",\n+    \"reasoning\",\n+    \"thought\",\n )\n \n \n def _strip_reasoning_tags(text: str) -> str:\n+    \"\"\"Remove reasoning/thinking blocks from displayed text.\n+\n+    Handles every case:\n+      * Closed pairs ``<tag>…</tag>`` (case-insensitive, multi-line).\n+      * Unterminated open tags that run to end-of-text (e.g. truncated\n+        generations on NIM/MiniMax where the close tag is dropped).\n+      * Stray orphan close tags (``stuff</think>answer``) left behind by\n+        partial-content dumps.\n+\n+    Covers the variants emitted by reasoning models today: ``<think>``,\n+    ``<thinking>``, ``<reasoning>``, ``<REASONING_SCRATCHPAD>``, and\n+    ``<thought>`` (Gemma 4).  Must stay in sync with\n+    ``run_agent.py::_strip_think_blocks`` and the stream consumer's\n+    ``_OPEN_THINK_TAGS`` / ``_CLOSE_THINK_TAGS`` tuples.\n+    \"\"\"\n     cleaned = text\n     for tag in _REASONING_TAGS:\n-        cleaned = re.sub(rf\"<{tag}>.*?</{tag}>\\s*\", \"\", cleaned, flags=re.DOTALL)\n-        cleaned = re.sub(rf\"<{tag}>.*$\", \"\", cleaned, flags=re.DOTALL)\n+        # Closed pair — case-insensitive so <THINK>…</THINK> is handled too.\n+        cleaned = re.sub(\n+            rf\"<{tag}>.*?</{tag}>\\s*\",\n+            \"\",\n+            cleaned,\n+            flags=re.DOTALL | re.IGNORECASE,\n+        )\n+        # Unterminated open tag — strip from the tag to end of text.\n+        cleaned = re.sub(\n+            rf\"<{tag}>.*$\",\n+            \"\",\n+            cleaned,\n+            flags=re.DOTALL | re.IGNORECASE,\n+        )\n+        # Stray orphan close tag left behind by partial dumps.\n+        cleaned = re.sub(\n+            rf\"</{tag}>\\s*\",\n+            \"\",\n+            cleaned,\n+            flags=re.IGNORECASE,\n+        )\n     return cleaned.strip()\n \n "]},{"file":"run_agent.py","hunks":["@@ -2172,17 +2172,49 @@ def _has_content_after_think_block(self, content: str) -> bool:\n         return bool(cleaned.strip())\n     \n     def _strip_think_blocks(self, content: str) -> str:\n-        \"\"\"Remove reasoning/thinking blocks from content, returning only visible text.\"\"\"\n+        \"\"\"Remove reasoning/thinking blocks from content, returning only visible text.\n+\n+        Handles four cases:\n+          1. Closed tag pairs (``<think>…</think>``) — the common path when\n+             the provider emits complete reasoning blocks.\n+          2. Unterminated open tag at a block boundary (start of text or\n+             after a newline) — e.g. MiniMax M2.7 / NIM endpoints where the\n+             closing tag is dropped.  Everything from the open tag to end\n+             of string is stripped.  The block-boundary check mirrors\n+             ``gateway/stream_consumer.py``'s filter so models that mention\n+             ``<think>`` in prose aren't over-stripped.\n+          3. Stray orphan open/close tags that slip through.\n+          4. Tag variants: ``<think>``, ``<thinking>``, ``<reasoning>``,\n+             ``<REASONING_SCRATCHPAD>``, ``<thought>`` (Gemma 4), all\n+             case-insensitive.\n+        \"\"\"\n         if not content:\n             return \"\"\n-        # Strip all reasoning tag variants: <think>, <thinking>, <THINKING>,\n-        # <reasoning>, <REASONING_SCRATCHPAD>, <thought> (Gemma 4)\n-        content = re.sub(r'<think>.*?</think>', '', content, flags=re.DOTALL)\n+        # 1. Closed tag pairs — case-insensitive for all variants so\n+        #    mixed-case tags (<THINK>, <Thinking>) don't slip through to\n+        #    the unterminated-tag pass and take trailing content with them.\n+        content = re.sub(r'<think>.*?</think>', '', content, flags=re.DOTALL | re.IGNORECASE)\n         content = re.sub(r'<thinking>.*?</thinking>', '', content, flags=re.DOTALL | re.IGNORECASE)\n-        content = re.sub(r'<reasoning>.*?</reasoning>', '', content, flags=re.DOTALL)\n-        content = re.sub(r'<REASONING_SCRATCHPAD>.*?</REASONING_SCRATCHPAD>', '', content, flags=re.DOTALL)\n+        content = re.sub(r'<reasoning>.*?</reasoning>', '', content, flags=re.DOTALL | re.IGNORECASE)\n+        content = re.sub(r'<REASONING_SCRATCHPAD>.*?</REASONING_SCRATCHPAD>', '', content, flags=re.DOTALL | re.IGNORECASE)\n         content = re.sub(r'<thought>.*?</thought>', '', content, flags=re.DOTALL | re.IGNORECASE)\n-        content = re.sub(r'</?(?:think|thinking|reasoning|thought|REASONING_SCRATCHPAD)>\\s*', '', content, flags=re.IGNORECASE)\n+        # 2. Unterminated reasoning block — open tag at a block boundary\n+        #    (start of text, or after a newline) with no matching close.\n+        #    Strip from the tag to end of string.  Fixes #8878 / #9568\n+        #    (MiniMax M2.7 leaking raw reasoning into assistant content).\n+        content = re.sub(\n+            r'(?:^|\\n)[ \\t]*<(?:think|thinking|reasoning|thought|REASONING_SCRATCHPAD)\\b[^>]*>.*$',\n+            '',\n+            content,\n+            flags=re.DOTALL | re.IGNORECASE,\n+        )\n+        # 3. Stray orphan open/close tags that slipped through.\n+        content = re.sub(\n+            r'</?(?:think|thinking|reasoning|thought|REASONING_SCRATCHPAD)>\\s*',\n+            '',\n+            content,\n+            flags=re.IGNORECASE,\n+        )\n         return content\n \n     @staticmethod","@@ -7262,6 +7294,20 @@ def _build_assistant_message(self, assistant_message, finish_reason: str) -> dic\n         if reasoning_text:\n             reasoning_text = _sanitize_surrogates(reasoning_text)\n \n+        # Strip inline reasoning tags (<think>…</think> etc.) from the stored\n+        # assistant content.  Reasoning was already captured into\n+        # ``reasoning_text`` above (either from structured fields or the\n+        # inline-block fallback), so the raw tags in content are redundant.\n+        # Leaving them in place caused reasoning to leak to messaging\n+        # platforms (#8878, #9568), inflate context on subsequent turns\n+        # (#9306 observed 16% content-size reduction on a real MiniMax\n+        # session), and pollute generated session titles.  One strip at the\n+        # storage boundary cleans content for every downstream consumer:\n+        # API replay, session transcript, gateway delivery, CLI display,\n+        # compression, title generation.\n+        if isinstance(_san_content, str) and _san_content:\n+            _san_content = self._strip_think_blocks(_san_content).strip()\n+\n         msg = {\n             \"role\": \"assistant\",\n             \"content\": _san_content,"]},{"file":"tests/cli/test_resume_display.py","hunks":["@@ -344,6 +344,127 @@ def test_pure_reasoning_message_skipped(self):\n         assert \"Just thinking\" not in output\n         assert \"Hi there!\" in output\n \n+    def test_think_tags_stripped(self):\n+        \"\"\"<think>...</think> blocks should be stripped from display (#11316).\"\"\"\n+        cli = _make_cli()\n+        cli.conversation_history = [\n+            {\"role\": \"user\", \"content\": \"Solve this\"},\n+            {\n+                \"role\": \"assistant\",\n+                \"content\": \"<think>\\nI need to reason carefully here.\\n</think>\\n\\nThe answer is 7.\",\n+            },\n+        ]\n+        output = self._capture_display(cli)\n+\n+        assert \"<think>\" not in output\n+        assert \"</think>\" not in output\n+        assert \"I need to reason carefully here\" not in output\n+        assert \"The answer is 7\" in output\n+\n+    def test_thinking_tags_stripped(self):\n+        \"\"\"<thinking>...</thinking> blocks should be stripped from display.\"\"\"\n+        cli = _make_cli()\n+        cli.conversation_history = [\n+            {\"role\": \"user\", \"content\": \"What is 2+2?\"},\n+            {\n+                \"role\": \"assistant\",\n+                \"content\": \"<thinking>\\nLet me compute: 2 + 2 = 4\\n</thinking>\\n\\nThe answer is 4.\",\n+            },\n+        ]\n+        output = self._capture_display(cli)\n+\n+        assert \"<thinking>\" not in output\n+        assert \"Let me compute\" not in output\n+        assert \"The answer is 4\" in output\n+\n+    def test_reasoning_tags_stripped(self):\n+        \"\"\"<reasoning>...</reasoning> blocks should be stripped from display.\"\"\"\n+        cli = _make_cli()\n+        cli.conversation_history = [\n+            {\"role\": \"user\", \"content\": \"Explain gravity\"},\n+            {\n+                \"role\": \"assistant\",\n+                \"content\": (\n+                    \"<reasoning>\\nGravity is a fundamental force...\\n</reasoning>\\n\\n\"\n+                    \"Gravity pulls objects together.\"\n+                ),\n+            },\n+        ]\n+        output = self._capture_display(cli)\n+\n+        assert \"<reasoning>\" not in output\n+        assert \"fundamental force\" not in output\n+        assert \"Gravity pulls objects together\" in output\n+\n+    def test_thought_tags_stripped(self):\n+        \"\"\"<thought>...</thought> blocks (Gemma 4) should be stripped.\"\"\"\n+        cli = _make_cli()\n+        cli.conversation_history = [\n+            {\"role\": \"user\", \"content\": \"Say hello\"},\n+            {\n+                \"role\": \"assistant\",\n+                \"content\": \"<thought>\\nInternal thought here.\\n</thought>\\n\\nHello!\",\n+            },\n+        ]\n+        output = self._capture_display(cli)\n+\n+        assert \"<thought>\" not in output\n+        assert \"Internal thought here\" not in output\n+        assert \"Hello!\" in output\n+\n+    def test_unclosed_think_tag_stripped(self):\n+        \"\"\"Unclosed <think> (truncated generation) should not leak reasoning.\"\"\"\n+        cli = _make_cli()\n+        cli.conversation_history = [\n+            {\"role\": \"user\", \"content\": \"Truncated response\"},\n+            {\n+                \"role\": \"assistant\",\n+                \"content\": \"Some text before.\\n<think>\\nUnfinished reasoning...\",\n+            },\n+        ]\n+        output = self._capture_display(cli)\n+\n+        assert \"<think>\" not in output\n+        assert \"Unfinished reasoning\" not in output\n+        assert \"Some text before\" in output\n+\n+    def test_multiple_reasoning_blocks_all_stripped(self):\n+        \"\"\"Multiple interleaved reasoning blocks are all stripped.\"\"\"\n+        cli = _make_cli()\n+        cli.conversation_history = [\n+            {\"role\": \"user\", \"content\": \"Complex question\"},\n+            {\n+                \"role\": \"assistant\",\n+                \"content\": (\n+                    \"<think>\\nFirst thought.\\n</think>\\n\"\n+                    \"Partial text.\\n\"\n+                    \"<reasoning>\\nSecond thought.\\n</reasoning>\\n\"\n+                    \"Final answer.\"\n+                ),\n+            },\n+        ]\n+        output = self._capture_display(cli)\n+\n+        assert \"First thought\" not in output\n+        assert \"Second thought\" not in output\n+        assert \"Partial text\" in output\n+        assert \"Final answer\" in output\n+\n+    def test_orphan_closing_think_tag_stripped(self):\n+        \"\"\"A stray </think> with no matching open should not render to user.\"\"\"\n+        cli = _make_cli()\n+        cli.conversation_history = [\n+            {\"role\": \"user\", \"content\": \"Broken output\"},\n+            {\n+                \"role\": \"assistant\",\n+                \"content\": \"some leftover reasoning</think>Visible answer.\",\n+            },\n+        ]\n+        output = self._capture_display(cli)\n+\n+        assert \"</think>\" not in output\n+        assert \"Visible answer\" in output\n+\n     def test_assistant_with_text_and_tool_calls(self):\n         \"\"\"When an assistant message has both text content AND tool_calls.\"\"\"\n         cli = _make_cli()"]},{"file":"tests/run_agent/test_run_agent.py","hunks":["@@ -317,6 +317,60 @@ def test_orphaned_thought_tag(self, agent):\n         result = agent._strip_think_blocks(\"<thought>orphaned reasoning without close\")\n         assert \"<thought>\" not in result\n \n+    # ─── Unterminated-block coverage (#8878, #9568, #10408) ──────────────\n+    # Reasoning models served via NIM / MiniMax M2.7 frequently drop the\n+    # closing tag, leaking raw reasoning into assistant content. The open\n+    # tag appears at a block boundary (start of text or after a newline);\n+    # everything from that tag to end-of-string is stripped.\n+\n+    def test_unterminated_think_block_content_stripped(self, agent):\n+        \"\"\"Content after unterminated <think> is fully stripped.\"\"\"\n+        result = agent._strip_think_blocks(\"<think>orphaned reasoning without close\")\n+        assert \"orphaned reasoning\" not in result\n+        assert result.strip() == \"\"\n+\n+    def test_unterminated_thought_block_content_stripped(self, agent):\n+        \"\"\"Gemma-style <thought> with no close is fully stripped.\"\"\"\n+        result = agent._strip_think_blocks(\"<thought>orphaned reasoning without close\")\n+        assert \"orphaned reasoning\" not in result\n+        assert result.strip() == \"\"\n+\n+    def test_unterminated_multiline_block_stripped(self, agent):\n+        \"\"\"Multi-line unterminated blocks are stripped in full.\"\"\"\n+        result = agent._strip_think_blocks(\n+            \"<think>\\nmulti\\nline\\nreasoning\\nthat never closes\"\n+        )\n+        assert \"multi\" not in result\n+        assert \"never closes\" not in result\n+\n+    def test_unterminated_block_after_answer_preserves_prefix(self, agent):\n+        \"\"\"Visible answer before a line-starting unterminated tag is kept.\"\"\"\n+        result = agent._strip_think_blocks(\n+            \"Answer is 42.\\n<think>actually let me reconsider\"\n+        )\n+        assert \"Answer is 42.\" in result\n+        assert \"reconsider\" not in result\n+\n+    def test_inline_think_mention_in_prose_not_over_stripped(self, agent):\n+        \"\"\"Mid-line `<think>` mentioned in prose must not swallow the rest\n+        of the content (the block-boundary check prevents this).\"\"\"\n+        text = \"Use the <think> tag like this in your prose.\"\n+        result = agent._strip_think_blocks(text)\n+        # Block-boundary check prevents unterminated-strip from firing\n+        assert \"prose\" in result\n+        assert \"Use the\" in result\n+\n+    def test_mixed_case_closed_pair_stripped(self, agent):\n+        \"\"\"Mixed-case variants <THINK>…</THINK>, <Thinking>…</Thinking> are\n+        handled by case-insensitive closed-pair regex, so the trailing\n+        content is preserved.\"\"\"\n+        result = agent._strip_think_blocks(\"<THINK>upper</THINK>final\")\n+        assert \"upper\" not in result\n+        assert \"final\" in result\n+        result = agent._strip_think_blocks(\"<Thinking>mixed</Thinking>final\")\n+        assert \"mixed\" not in result\n+        assert \"final\" in result\n+\n \n class TestExtractReasoning:\n     def test_reasoning_field(self, agent):","@@ -1088,6 +1142,41 @@ def test_tool_call_without_extra_content(self, agent):\n         result = agent._build_assistant_message(msg, \"tool_calls\")\n         assert \"extra_content\" not in result[\"tool_calls\"][0]\n \n+    def test_think_blocks_stripped_from_content(self, agent):\n+        \"\"\"Inline <think> blocks are stripped from stored content (#8878, #9568).\n+\n+        The reasoning is captured into ``msg['reasoning']`` via the inline\n+        fallback in ``_extract_reasoning``; the raw tags in ``content`` are\n+        redundant and leak to messaging platforms / pollute titles /\n+        inflate context if left in place.\n+        \"\"\"\n+        msg = _mock_assistant_msg(\n+            content=\"<think>internal reasoning</think>The actual answer.\"\n+        )\n+        result = agent._build_assistant_message(msg, \"stop\")\n+        assert \"<think>\" not in result[\"content\"]\n+        assert \"internal reasoning\" not in result[\"content\"]\n+        assert \"The actual answer.\" in result[\"content\"]\n+        # Reasoning preserved separately via inline extraction fallback\n+        assert result[\"reasoning\"] == \"internal reasoning\"\n+\n+    def test_think_blocks_stripped_preserves_normal_content(self, agent):\n+        \"\"\"Content without reasoning tags passes through unchanged.\"\"\"\n+        msg = _mock_assistant_msg(content=\"No thinking here.\")\n+        result = agent._build_assistant_message(msg, \"stop\")\n+        assert result[\"content\"] == \"No thinking here.\"\n+\n+    def test_unterminated_think_block_stripped(self, agent):\n+        \"\"\"Unterminated <think> block (MiniMax / NIM dropped close tag) is\n+        fully stripped from stored content.\"\"\"\n+        msg = _mock_assistant_msg(\n+            content=\"<think>reasoning that never closes on this NIM endpoint\"\n+        )\n+        result = agent._build_assistant_message(msg, \"stop\")\n+        assert \"<think>\" not in result[\"content\"]\n+        assert \"reasoning that never closes\" not in result[\"content\"]\n+        assert result[\"content\"] == \"\"\n+\n \n class TestFormatToolsForSystemMessage:\n     def test_no_tools_returns_empty_array(self, agent):\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}