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:
Xubin Ren
2026-05-12 17:14:19 +00:00
co-authored by Cursor
parent 3a851f8f8d
commit 352aaf0627
9 changed files with 281 additions and 70 deletions
+47 -1
View File
@@ -1,4 +1,4 @@
from nanobot.utils.helpers import extract_think, strip_think
from nanobot.utils.helpers import extract_reasoning, extract_think, strip_think
class TestStripThinkTag:
@@ -225,3 +225,49 @@ squares = [x**2 for x in range(10)]
assert "List comprehensions in Python" in clean
assert "<think>" not in clean
assert "</think>" not in clean
class TestExtractReasoning:
"""Single source of truth for reasoning extraction across all providers."""
def test_prefers_reasoning_content_and_strips_inline_think(self):
# Dedicated field wins; inline tags are still scrubbed from content.
reasoning, content = extract_reasoning(
"dedicated",
None,
"<think>inline</think>visible answer",
)
assert reasoning == "dedicated"
assert content == "visible answer"
def test_falls_back_to_thinking_blocks(self):
reasoning, content = extract_reasoning(
None,
[
{"type": "thinking", "thinking": "step 1"},
{"type": "thinking", "thinking": "step 2"},
{"type": "redacted_thinking"},
],
"hello",
)
assert reasoning == "step 1\n\nstep 2"
assert content == "hello"
def test_falls_back_to_inline_think_tags(self):
reasoning, content = extract_reasoning(
None, None, "<think>plan</think>answer"
)
assert reasoning == "plan"
assert content == "answer"
def test_no_reasoning_returns_none(self):
reasoning, content = extract_reasoning(None, None, "plain answer")
assert reasoning is None
assert content == "plain answer"
def test_empty_thinking_blocks_falls_through_to_inline(self):
reasoning, content = extract_reasoning(
None, [], "<think>plan</think>answer"
)
assert reasoning == "plan"
assert content == "answer"