From 3b739b990b3f624a6cbd823c6b91bf27d83a694a Mon Sep 17 00:00:00 2001 From: shawchanshek <53571168+shawchanshek@users.noreply.github.com> Date: Wed, 1 Jul 2026 03:28:29 -0700 Subject: [PATCH] fix(title_generator): strip think blocks from LLM output before extracting title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Think-enabled models (MiniMax M2.7, DeepSeek, etc.) emit inline ... reasoning even for simple prompts like title generation, and the raw XML was leaking into session titles. Route the title-model response through the canonical strip_think_blocks scrubber before cleanup so every tag variant — closed pairs, unterminated blocks, orphan closes, mixed case — is handled, not just a single literal pair. - 2 regression tests: closed pair stripped, unterminated block at start yields no title. Salvaged from PR #44126 by @shawchanshek. --- agent/title_generator.py | 10 +++++++++- scripts/release.py | 1 + tests/agent/test_title_generator.py | 31 +++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/agent/title_generator.py b/agent/title_generator.py index c7ce2375fc9..5534b34710d 100644 --- a/agent/title_generator.py +++ b/agent/title_generator.py @@ -87,7 +87,15 @@ def generate_title( timeout=timeout, main_runtime=main_runtime, ) - title = (response.choices[0].message.content or "").strip() + content = response.choices[0].message.content or "" + # Strip thinking/reasoning blocks that think-enabled models + # (MiniMax M2.7, DeepSeek, etc.) emit even for simple prompts like + # title generation. Without this the raw ... XML + # leaks into session titles. Reuses the canonical scrubber so all + # tag variants (unterminated blocks, orphan closes, mixed case) + # are handled, not just a single literal pair. + from agent.agent_runtime_helpers import strip_think_blocks + title = strip_think_blocks(None, content).strip() # Clean up: remove quotes, trailing punctuation, prefixes like "Title: " title = title.strip('"\'') if title.lower().startswith("title:"): diff --git a/scripts/release.py b/scripts/release.py index f537c6158ad..e2a4cebf0d8 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -48,6 +48,7 @@ AUTHOR_MAP = { "gary@bitcryptic.com": "bitcryptic-gw", # PR #53997 salvage (Matrix E2EE: resolve device_id via query_keys({mxid: []}) when whoami returns none; guard verification call sites so query_keys is never sent [null]; reset _device_id_unverified at connect() start; disconnect before reconnect) "7698789+abchiaravalle@users.noreply.github.com": "abchiaravalle", # PR #46997 salvage (recover resume_pending sessions: dual freshness signal + empty-turn safety net so restart auto-resume never sends a blank user turn) "swissly@users.noreply.github.com": "swissly", # PR #47167 salvage (wrap cron delivery thread-pool fallback in its own try/except so a per-target failure can't escape the except-RuntimeError block and crash the multi-target delivery loop; #47163) + "53571168+shawchanshek@users.noreply.github.com": "shawchanshek", # PR #44126 salvage (strip ... reasoning blocks from title-generator LLM output via the canonical strip_think_blocks scrubber so reasoning-model output can't leak into session titles) "30854794+YLChen-007@users.noreply.github.com": "YLChen-007", # PR #27289 salvage (case-insensitive streaming reasoning-tag filter in cli.py _stream_delta + gateway stream_consumer so mixed-case variants like / are suppressed, not just the hardcoded case literals) "27672904+kangsoo-bit@users.noreply.github.com": "kangsoo-bit", # PR #47508 salvage (keep Telegram gateway alive on transient bootstrap network errors: best-effort deleteWebhook + resilient start_polling degrade to background recovery instead of failing startup) "259353979+testingbuddies24@users.noreply.github.com": "testingbuddies24", # PR #43192 salvage (strip orphan think-tag close tags in progressive gateway stream so a bare whose open was dropped upstream can't leak to the user) diff --git a/tests/agent/test_title_generator.py b/tests/agent/test_title_generator.py index bda0f0caf02..a04c018114d 100644 --- a/tests/agent/test_title_generator.py +++ b/tests/agent/test_title_generator.py @@ -99,6 +99,37 @@ class TestGenerateTitle: title = generate_title("how do I set up docker", "First install...") assert title == "Setting Up Docker Environment" + def test_strips_think_blocks(self): + """Reasoning-model output wrapped in ... must not + leak into the session title.""" + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = ( + "The user wants a title. I'll summarize the topic " + "concisely.Debugging Python Import Errors" + ) + + with patch("agent.title_generator.call_llm", return_value=mock_response): + title = generate_title("help me fix this import", "Sure...") + assert title == "Debugging Python Import Errors" + assert "" not in title + assert "summarize" not in title + + def test_strips_unterminated_think_block(self): + """An unterminated block (no close tag) must still be + stripped so the leaked reasoning doesn't become the title.""" + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = ( + "Let me reason about a good title for this session" + ) + + with patch("agent.title_generator.call_llm", return_value=mock_response): + title = generate_title("hello", "hi there") + # Everything from the unterminated open tag onward is stripped, + # leaving nothing → None. + assert title is None + def test_strips_title_prefix(self): mock_response = MagicMock() mock_response.choices = [MagicMock()]