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()]