refactor(reasoning): unify reasoning extraction across providers
Reasoning surfacing was split across three branches in runner.py plus two separate streaming buffers (loop hook and runner progress stream), with three independent display-side gates in the CLI. This collapsed the policy into one source of truth and fixed two real bugs: - Structured `reasoning_content` was suppressed whenever the answer was streamed, because the runner gated emission on `streamed_content`. Providers don't stream `reasoning_content`; it only arrives on the final response, so the answer stream and the reasoning channel are independent. Added `streamed_reasoning` to `AgentHookContext` to track the right bit. - `channels.showReasoning` was subordinated to `sendProgress`. They are orthogonal — turning off progress streaming shouldn't silence reasoning. Reworked the CLI gates accordingly. Single-helper consolidation: - `extract_reasoning(reasoning_content, thinking_blocks, content)` returns `(reasoning_text, cleaned_content)` with a defined fallback order: dedicated field → Anthropic thinking_blocks → inline `<think>`/`<thought>` tags. Models that expose none of these short-circuit to `(None, content)` — zero overhead. - `IncrementalThinkExtractor` replaces the ad-hoc `emit_incremental_think` function and its hand-rolled "emitted cursor" state in both the loop hook and the runner progress stream. Also documented the new `showReasoning` channel option in docs/configuration.md and noted its independence from sendProgress. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+71
-25
@@ -72,17 +72,11 @@ def strip_think(text: str) -> str:
|
||||
|
||||
|
||||
def extract_think(text: str) -> tuple[str | None, str]:
|
||||
"""Extract thinking/reasoning content from <think> and <thought> tags.
|
||||
"""Extract thinking content from inline ``<think>`` / ``<thought>`` blocks.
|
||||
|
||||
Returns (thinking_text, cleaned_text) where:
|
||||
- thinking_text: concatenated content from all <think>...</think> and
|
||||
<thought>...</thought> blocks, or None if none found.
|
||||
- cleaned_text: the input with all thinking blocks removed (same as
|
||||
strip_think()).
|
||||
|
||||
Only extracts from well-formed closed blocks. Unclosed trailing tags
|
||||
(common during streaming) are stripped without extraction — use
|
||||
strip_think() for pure streaming cleanup.
|
||||
Returns ``(thinking_text, cleaned_text)``. Only closed blocks are
|
||||
extracted; unclosed streaming prefixes are stripped from the cleaned
|
||||
text but not surfaced — :func:`strip_think` handles that case.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
for m in re.finditer(r"<think>([\s\S]*?)</think>", text):
|
||||
@@ -93,23 +87,75 @@ def extract_think(text: str) -> tuple[str | None, str]:
|
||||
return thinking, strip_think(text)
|
||||
|
||||
|
||||
async def emit_incremental_think(
|
||||
buf: str,
|
||||
emitted: str,
|
||||
emit_fn: Any,
|
||||
) -> str:
|
||||
"""Extract new thinking from buf and emit if not yet emitted.
|
||||
class IncrementalThinkExtractor:
|
||||
"""Stateful inline ``<think>`` extractor for streaming buffers.
|
||||
|
||||
Returns the updated emitted state. *emit_fn* is an async callable
|
||||
that accepts a single reasoning string (e.g. ``hook.emit_reasoning``).
|
||||
Streaming providers expose only a single content delta channel. When a
|
||||
model embeds reasoning in ``<think>...</think>`` blocks inside that
|
||||
channel, callers need to surface the reasoning incrementally as it
|
||||
arrives without re-emitting earlier text. This holds the "already
|
||||
emitted" cursor so the runner and the loop hook share one shape.
|
||||
"""
|
||||
thinking, _ = extract_think(buf)
|
||||
if thinking and thinking != emitted:
|
||||
new = thinking[len(emitted):]
|
||||
if new.strip():
|
||||
await emit_fn(new.strip())
|
||||
return thinking
|
||||
return emitted
|
||||
|
||||
__slots__ = ("_emitted",)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._emitted = ""
|
||||
|
||||
def reset(self) -> None:
|
||||
self._emitted = ""
|
||||
|
||||
async def feed(self, buf: str, emit: Any) -> bool:
|
||||
"""Emit any new thinking text found in ``buf``.
|
||||
|
||||
Returns True if anything was emitted this call. ``emit`` is an
|
||||
async callable taking a single string (typically
|
||||
``hook.emit_reasoning``).
|
||||
"""
|
||||
thinking, _ = extract_think(buf)
|
||||
if not thinking or thinking == self._emitted:
|
||||
return False
|
||||
new = thinking[len(self._emitted):].strip()
|
||||
self._emitted = thinking
|
||||
if not new:
|
||||
return False
|
||||
await emit(new)
|
||||
return True
|
||||
|
||||
|
||||
def extract_reasoning(
|
||||
reasoning_content: str | None,
|
||||
thinking_blocks: list[dict[str, Any]] | None,
|
||||
content: str | None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Return ``(reasoning_text, cleaned_content)`` from one model response.
|
||||
|
||||
Single source of truth for "what reasoning did this response carry, and
|
||||
what answer text remains after we peel it out". Fallback order:
|
||||
|
||||
1. Dedicated ``reasoning_content`` (DeepSeek-R1, Kimi, MiMo, OpenAI
|
||||
reasoning models, Bedrock).
|
||||
2. Anthropic ``thinking_blocks``.
|
||||
3. Inline ``<think>`` / ``<thought>`` blocks in ``content``.
|
||||
|
||||
Only one source contributes per response; lower-priority sources are
|
||||
ignored if a higher-priority one is present, but inline ``<think>``
|
||||
tags are still stripped from ``content`` so they never leak into the
|
||||
final answer.
|
||||
"""
|
||||
if reasoning_content:
|
||||
return reasoning_content, strip_think(content) if content else content
|
||||
if thinking_blocks:
|
||||
parts = [
|
||||
tb.get("thinking", "")
|
||||
for tb in thinking_blocks
|
||||
if isinstance(tb, dict) and tb.get("type") == "thinking"
|
||||
]
|
||||
joined = "\n\n".join(p for p in parts if p)
|
||||
return (joined or None), strip_think(content) if content else content
|
||||
if content:
|
||||
return extract_think(content)
|
||||
return None, content
|
||||
|
||||
|
||||
def detect_image_mime(data: bytes) -> str | None:
|
||||
|
||||
Reference in New Issue
Block a user