fix(title_generator): strip think blocks from LLM output before extracting title

Think-enabled models (MiniMax M2.7, DeepSeek, etc.) emit inline
<think>...</think> 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
<think> pair.

- 2 regression tests: closed <think> pair stripped, unterminated block
  at start yields no title.

Salvaged from PR #44126 by @shawchanshek.
This commit is contained in:
shawchanshek 2026-07-01 03:28:29 -07:00 committed by Teknium
parent 037e389c4f
commit 3b739b990b
3 changed files with 41 additions and 1 deletions

View file

@ -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 <think>...</think> 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 <think> 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:"):

View file

@ -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 <think>...</think> 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 <Think>/<ThInK> 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 </think> whose open was dropped upstream can't leak to the user)

View file

@ -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 <think>...</think> must not
leak into the session title."""
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = (
"<think>The user wants a title. I'll summarize the topic "
"concisely.</think>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 "<think>" not in title
assert "summarize" not in title
def test_strips_unterminated_think_block(self):
"""An unterminated <think> 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 = (
"<think>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()]