Title generator leaking think/reasoning blocks from LLM output

NousResearch/hermes-agent · cluster 18529 · agent

Data as of

Nothing open on GitHub. Fix merged in #56298. Check it covers your case.

The short answer

0open issues
0open PRs
9items grouped
Known?
Yes. 9 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #56298. 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.

Multiple fixes address the title generator reading raw LLM output that includes <think> or <reasoning> blocks instead of using the existing extract_content_or_reasoning() helper. One member also adds multi-message context, noise filtering, retry logic, and provider override to title generation.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

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

    merged to main

    files: agent/title_generator.py, scripts/release.py, tests/agent/test_title_generator.py

    Show the diff
    • 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 <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:"):
    • scripts/release.py
      @@ -48,6 +48,7 @@
           "[redacted:email]": "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)
           "[redacted:email]": "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)
           "[redacted:email]": "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)
      +    "[redacted:email]": "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)
           "[redacted:email]": "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)
           "[redacted:email]": "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)
           "[redacted:email]": "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)
    • tests/agent/test_title_generator.py
      @@ -68,6 +68,37 @@ def test_strips_quotes(self):
                   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()]

7 PRs closed without merging

From the close comments: done elsewhere 2 · closed without comment 1 · duplicate 1 · merged via another change 1 · superseded by another PR 1 · other 1

  • #18537 Fix/title generation thinking leak · done elsewhere → 3b739b9, #18529
  • #18643 fix: use extract_content_or_reasoning() for title generation with reasoning models · other → #17797, #20266
  • #19008 fix(title): strip thinking blocks from auto-generated session titles · merged via another change → 3b739b9, #44126, #56298
  • #20266 fix: support reasoning models in title generation · done elsewhere → 3b739b9, #18529
  • #22568 fix(title-generator): strip <think>/<reasoning> blocks before saving session title · duplicate → #19008, #18529
Show 2 more
  • #26676 feat(title-generator): multi-message context, noise filtering, retry, explicit provider override · closed without comment
  • #27763 fix(title-gen): use extract_content_or_reasoning for reasoning models · superseded by another PR → #18643, #20266, #20754, #18529
Closed issues · 1
  • Closed#18529[Bug]: title_generator.py read response.choices[0].message.content directly instead of use extract_content_or_reasoning() to extract <thinking> tag
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.22: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means 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 Lab

At a glance

Subsystem
agent
Merged PRs
#56298
Closed issues
1
Attempts
7 PRs closed without merging
Fix link checked
1 Jul 2026

For your agent

The same facts, typed.