Conversation
…think>
The two TTS text cleaners only removed <think>...</think>:
strip_nonspoken_blocks (batch normalizer, all auto-TTS / voice / tool paths)
and SentenceChunker (the streamed speaker + speak-stream WebSocket). Every
other reasoning scrubber in the codebase — agent/think_scrubber.py and
strip_think_blocks — covers <think>, <thinking>, <reasoning>, <thought> and
<REASONING_SCRATCHPAD>.
Models that don't use <think> — Gemini and Gemma (including the Vertex
OpenAI-compatible path), GLM, and others — emit <thinking>/<reasoning>/
<thought>. Their reasoning reached the speech provider verbatim and was read
aloud, defeating the whole point of these cleaners ("blocks that must never
reach a speech provider", NousResearch#34213). The streaming chunker was worse: no
re.IGNORECASE, so even <THINK> leaked, and its open-tag hold-back only knew
about <think>.
Share one canonical tag set (kept in sync with think_scrubber._OPEN_TAG_NAMES)
across both cleaners:
- strip_nonspoken_blocks strips all five variants, closed and unterminated,
case-insensitively.
- SentenceChunker strips them across deltas and holds a delta back on any
unclosed reasoning tag (new has_unclosed_reasoning_tag probe), not just
<think>.
No behaviour change for text without reasoning tags; the <think> path is
unchanged.
teknium1
left a comment
There was a problem hiding this comment.
Thanks for covering the non-variants; the underlying current-main gap is real: `tools/tts_text_normalize.py:215` and `tools/tts_streaming.py:86-107` only handle.
Problems
- The amended
SentenceChunker.flush()at PRtools/tts_streaming.py:137strips only closed pairs. Afterfeed("secret")holds the buffer,flush()returns the still-open raw tail. This reaches synthesis becausegateway/streaming_tts_consumer.py:182enqueues every flushed clause. hermes_cli/web_server.py:4644independently recognizes only lowercase `` before an idle force-flush, so the speak-stream path does not preserve the new variants across an idle gap.
Suggested changes
- Drop unterminated reasoning tails in
flush()and add parameterized flush/end-of-stream tests for every supported tag. - Replace the WebSocket's literal `` idle guard with the same variant-aware probe.
- Avoid a second tag-list source:
agent/think_scrubber.py:79-85already owns the current set.
Automated hermes-sweeper review.
| @@ -119,7 +137,7 @@ def feed(self, delta: str) -> List[str]: | |||
|
|
|||
There was a problem hiding this comment.
Blocking: this only removes closed pairs. feed("secret") returns [] via the new unclosed-tag guard, but an end-of-stream or idle flush() returns the raw open tail and the gateway synthesizes it. Discard or remove an unclosed reasoning suffix here, and add a regression test that calls flush() after every unclosed variant.
| # Reasoning blocks: models with ``/reasoning show`` enabled emit reasoning | ||
| # blocks in the final assistant message. Users want to SEE reasoning, not | ||
| # hear it read aloud (#34213). Cover every tag variant the canonical scrubber | ||
| # recognises (``agent/think_scrubber.py`` ``_OPEN_TAG_NAMES``) — not just |
There was a problem hiding this comment.
Suggestion: this is a second manually maintained source for the tag set, while agent/think_scrubber.py:79-85 already defines it. Please expose/import one shared constant so future reasoning-tag additions cannot silently diverge between display and TTS suppression.
Review follow-ups on the reasoning-variant fix: 1. SentenceChunker.flush() stripped only closed pairs, so when feed() held a delta back on an open reasoning tag and the stream then ended (or idled out) with no close, flush() returned the raw open tail — and the gateway (streaming_tts_consumer) synthesises every flushed clause. Drop the unterminated block on flush too, via a shared strip_unterminated_reasoning() helper. 2. The speak-stream idle force-flush guard in web_server.py recognised only a lowercase <think open tag, so the WebSocket path didn't hold non-<think> variants across an idle gap. Replace the literal check with the same has_unclosed_reasoning_tag() probe. 3. Single source of truth for the tag set: agent/think_scrubber.py now exposes REASONING_TAG_NAMES; tts_text_normalize imports it instead of keeping a second hand-maintained copy, so a future variant can't be scrubbed for display yet spoken aloud. Tests: parameterized flush / end-of-stream coverage for every tag (open tail dropped, visible prefix kept), split-across-deltas unterminated case, and a guard that the TTS tag set is the canonical scrubber's object.
Verification comment (Vox Lockin lane 10 — adversarial check)Verified this PR against current Premise confirmed on main: both TTS cleaners strip only
so a model emitting Checked against this PR's head
CI: all required checks pass (run 30799200885; ruff, footguns, check-attribution, Docker builds green). Composition note (lane 10): this PR is orthogonal to my streaming-truncation work (#78234) — different code paths (tag stripping vs. length splitting). No conflicts expected. |
What
The two TTS text cleaners only removed
<think>...</think>:strip_nonspoken_blocks(batch normalizer — every auto-TTS / voice / tool path viaprepare_spoken_text)SentenceChunker(the streamed speaker pipeline + speak-stream WebSocket)Every other reasoning scrubber in the codebase —
agent/think_scrubber.py(_OPEN_TAG_NAMES) andstrip_think_blocks— covers five variants:<think>, `")before: 'The answer is 42.' ← spoken
after : 'The answer is 42.'
SentenceChunker, "hidden" split across deltas
before: reasoning spoken
after : held until close, stripped
scripts/run_tests.sh tests/tools/test_tts_text_normalize.py tests/tools/test_tts_streaming.py
with the fix
=== Summary: 13 passed (text_normalize) / all SentenceChunker green ===
without the fix — genuine behavioural failures (not import errors)
test_tts_text_normalize.py 9 failed ( params still pass — already covered)
test_tts_streaming.py 5 failed