fix(agent): feed inline <think> text to the live reasoning pane (#89647)

The desktop/TUI reasoning pane is driven by reasoning.delta via
reasoning_callback; the scrubber-side collector only filled the final
reasoning_content, which extract_reasoning already recovers from the raw
content, so the pane stayed dead.

- Drop the scrubber reasoning collector (_reasoning_parts, reasoning(),
  clear_reasoning(), \x00 sentinel, _THINK_TAG_RE) and its per-request
  reset hook.
- StreamingThinkScrubber.feed() exposes the text it stripped from inside
  think blocks as last_hidden; _fire_stream_delta forwards it through
  _fire_reasoning_delta(inline=True) while no native reasoning delta has
  arrived for this model response (reset per request) — no double
  reasoning. CLI gating is unchanged: its reasoning_callback is None
  unless show_reasoning/verbose.
- _finish_chat_stream fills reasoning_content from the raw content via
  the existing extract_reasoning when no reasoning delta arrived.
- Replace the collector tests with two guards (live forwarding + native
  suppression; _finish_chat_stream fallback), both red on origin/main.

Co-authored-by: SayHell0W0rld <852938468@qq.com>
This commit is contained in:
kshitijk4poor
2026-09-24 21:42:06 +05:30
committed by kshitij
parent 5c70b151fc
commit 572446308e
5 changed files with 61 additions and 70 deletions

View File

@@ -3269,13 +3269,12 @@ class _StreamingCall(StreamingWaitMonitor):
args or stamping "stop"."""
full_content = "".join(content_parts) or None
full_reasoning = "".join(reasoning_parts) or None
if not full_reasoning:
# Providers that inline reasoning (MiniMax-M3 streams <think>…</think> in content) never
# send a reasoning delta — recover what the think scrubber stripped so the structured
# reasoning_content field stays populated (#89647).
think_scrubber = getattr(self.agent, "_stream_think_scrubber", None)
if think_scrubber is not None:
full_reasoning = think_scrubber.reasoning() or None
if not full_reasoning and full_content:
# Inline-reasoning providers (MiniMax-M3 streams <think>…</think> in content) send no
# reasoning delta; fill the structured field from the raw content (#89647).
from agent.agent_runtime_helpers import extract_reasoning
full_reasoning = extract_reasoning(self.agent, SimpleNamespace(content=full_content))
mock_tool_calls, has_truncated_tool_args = self._assemble_tool_calls(tool_calls_acc, finish_reason)
# Zero-chunk guard: nothing usable = upstream error / malformed SSE.
if finish_reason is None and not content_parts and not reasoning_parts and not refusal_parts and not tool_calls_acc:

View File

@@ -52,6 +52,9 @@ class StreamDeliveryMixin:
"""
think_scrubber = getattr(self, "_stream_think_scrubber", None)
ctx_scrubber = getattr(self, "_stream_context_scrubber", None)
# Inline <think> text is forwarded to the reasoning pane only while no native reasoning delta
# arrived for this model response (#89647).
self._native_reasoning_streamed = False
# Next stream re-reads plugins.stream_reasoning_deltas (config edits land per request).
self._stream_reasoning_hooks_enabled = None
@@ -72,10 +75,6 @@ class StreamDeliveryMixin:
if think_scrubber is not None:
think_tail = think_scrubber.flush()
deliver(ctx_scrubber.feed(think_tail) if think_tail and ctx_scrubber is not None else think_tail)
# Inline reasoning recovered for reasoning_content is per model response (#89647).
clear_reasoning = getattr(think_scrubber, "clear_reasoning", None)
if callable(clear_reasoning):
clear_reasoning()
if ctx_scrubber is not None:
deliver(ctx_scrubber.flush())
self._current_streamed_assistant_text = ""
@@ -308,6 +307,11 @@ class StreamDeliveryMixin:
# See #5719.
scrubber = getattr(self, "_stream_context_scrubber", None)
text = think_scrubber.feed(text) if think_scrubber is not None else self._strip_think_blocks(text)
# Providers that inline reasoning (MiniMax-M3 <think>…</think>) send no reasoning delta, so the
# live reasoning pane would stay empty; forward what the scrubber stripped instead (#89647).
hidden = think_scrubber.last_hidden if think_scrubber is not None else ""
if hidden and not getattr(self, "_native_reasoning_streamed", False):
self._fire_reasoning_delta(hidden, inline=True)
text = scrubber.feed(text) if scrubber is not None else sanitize_context(text)
# Only strip leading newlines on the first delta — mid-stream "\n" is legitimate markdown.
# Check the parts list, not the joined property (joining per token copies the whole reply).
@@ -320,8 +324,13 @@ class StreamDeliveryMixin:
if delivered:
self._record_streamed_assistant_text(text)
def _fire_reasoning_delta(self, text: str) -> None:
"""Fire reasoning callback if registered; superseded writers are fenced like content deltas."""
def _fire_reasoning_delta(self, text: str, *, inline: bool = False) -> None:
"""Fire reasoning callback if registered; superseded writers are fenced like content deltas.
``inline`` marks text recovered from ``<think>`` blocks in content; any other call is a native
provider reasoning delta and stops inline forwarding for the rest of this model response."""
if not inline:
self._native_reasoning_streamed = True
if self._stream_writer_superseded():
# Single-writer guard (#65991): fence out a superseded stream's reasoning deltas the same way as
# content deltas.

View File

@@ -23,13 +23,6 @@ THINK_TAG_NAMES: Tuple[str, ...] = ("think", "thinking", "reasoning", "thought",
THINK_OPEN_TAGS: Tuple[str, ...] = tuple(f"<{name.lower()}>" for name in THINK_TAG_NAMES)
THINK_CLOSE_TAGS: Tuple[str, ...] = tuple(f"</{name.lower()}>" for name in THINK_TAG_NAMES)
# Tag markup stripped by reasoning(); matches every variant the scrubber
# recognizes (open, close, case-insensitive).
_THINK_TAG_RE = re.compile(
r"<\s*/?\s*(?:" + "|".join(THINK_TAG_NAMES) + r")\s*>",
re.IGNORECASE,
)
class StreamingThinkScrubber:
"""Stateful scrubber for streaming reasoning/thinking blocks.
@@ -44,8 +37,6 @@ class StreamingThinkScrubber:
_CLOSE_TAGS: Tuple[str, ...] = THINK_CLOSE_TAGS
_ALL_TAGS: Tuple[str, ...] = _OPEN_TAGS + _CLOSE_TAGS
_MAX_TAG_LEN: int = max(len(tag) for tag in _ALL_TAGS)
# Separates collected reasoning blocks; streamed pieces of one block rejoin verbatim.
_BLOCK_END: str = "\x00"
# Orphan close tag plus trailing whitespace (matches _strip_think_blocks case 3).
_ORPHAN_CLOSE_RE = re.compile(
"(?:" + "|".join(re.escape(t) for t in _CLOSE_TAGS) + r")[ \t\n\r]*", re.IGNORECASE
@@ -59,7 +50,8 @@ class StreamingThinkScrubber:
self._in_block: bool = False
self._buf: str = ""
self._last_emitted_ended_newline: bool = True
self._reasoning_parts: list[str] = []
# Reasoning text the most recent feed() stripped from inside think blocks (tags excluded).
self.last_hidden: str = ""
def _emit(self, out: list[str], text: str) -> None:
"""Append visible prose to *out* (orphan close tags stripped) and track the newline flag."""
@@ -70,24 +62,22 @@ class StreamingThinkScrubber:
def feed(self, text: str) -> str:
"""Feed one delta; return the scrubbed visible portion ("" when it is all reasoning or held back)."""
self.last_hidden = ""
if not text:
return ""
buf = self._buf + text
self._buf = ""
out: list[str] = []
hidden: list[str] = []
while buf:
if self._in_block:
close_idx, close_len = self._find_first_tag(buf, self._CLOSE_TAGS)
if close_idx == -1:
# No close yet: hold back a possible partial close-tag prefix; collect
# the rest as reasoning (#89647).
discard = self._hold_partial(buf, self._CLOSE_TAGS)
if discard:
self._reasoning_parts.append(discard)
# No close yet: hold back a possible partial close-tag prefix; the rest is reasoning.
hidden.append(self._hold_partial(buf, self._CLOSE_TAGS))
break
# Found close: collect block content as reasoning (#89647).
self._reasoning_parts.append(buf[:close_idx] + self._BLOCK_END)
hidden.append(buf[:close_idx])
buf = buf[close_idx + close_len:]
self._in_block = False
continue
@@ -99,8 +89,8 @@ class StreamingThinkScrubber:
open_idx, open_len = self._find_open_at_boundary(buf, out)
if pair is not None and (open_idx == -1 or pair[0] <= open_idx):
self._emit(out, buf[:pair[0]])
# Collect the stripped pair (tags removed by reasoning()) (#89647).
self._reasoning_parts.append(buf[pair[0]:pair[1]] + self._BLOCK_END)
# Pair tags are exact ``<name>``/``</name>``: inner text sits between them.
hidden.append(buf[buf.index(">", pair[0]) + 1:buf.rindex("<", pair[0], pair[1])])
buf = buf[pair[1]:]
continue
if open_idx != -1:
@@ -114,6 +104,7 @@ class StreamingThinkScrubber:
self._emit(out, self._hold_partial(buf, self._ALL_TAGS))
break
self.last_hidden = "".join(hidden)
return "".join(out)
def _hold_partial(self, buf: str, tags: Tuple[str, ...]) -> str:
@@ -127,28 +118,12 @@ class StreamingThinkScrubber:
partial reasoning is worse than a truncated answer), otherwise the tail is emitted verbatim.
Always resets the boundary flag — intra-turn retries flush then stream again without ``reset()``,
and a stale False flag made the new stream's opening ``<think>`` look mid-line."""
if self._in_block:
self._reasoning_parts.append(self._buf + self._BLOCK_END)
tail = "" if self._in_block else self._buf
self._buf = ""
self._in_block = False
self._last_emitted_ended_newline = True
return self._strip_orphan_close_tags(tail) if tail else ""
def reasoning(self) -> str:
"""Reasoning text stripped from streamed content so far, tag markup removed ("" when none).
Lets callers populate a structured ``reasoning_content`` field for providers (e.g. MiniMax-M3)
that inline reasoning instead of returning a reasoning delta (#89647).
"""
blocks = _THINK_TAG_RE.sub("", "".join(self._reasoning_parts)).split(self._BLOCK_END)
return "\n".join(t for t in (b.strip() for b in blocks) if t)
def clear_reasoning(self) -> None:
"""Drop collected reasoning. Called per model request so one API call's inline reasoning
does not bleed into the next call's ``reasoning_content`` within the same turn."""
self._reasoning_parts = []
# ── internal helpers ───────────────────────────────────────────────
@staticmethod