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
Open PRs0
Fixes on record
-
#12361 fix: eliminate <think> leakage across storage, API replay, and /resume
files:
cli.py,run_agent.py,tests/cli/test_resume_display.py,tests/run_agent/test_run_agent.pyShow 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
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.00means 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 LabAt 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.