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
+105
View File
@@ -227,6 +227,111 @@ async def test_runner_prefers_reasoning_content_over_inline_think():
assert emitted_reasoning[0] == "dedicated reasoning field"
@pytest.mark.asyncio
async def test_runner_emits_reasoning_content_even_when_answer_was_streamed():
"""`reasoning_content` arrives only on the final response; streaming the
answer must not suppress it (the answer stream and the reasoning channel
are independent — only the reasoning-already-emitted bit matters)."""
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.runner import AgentRunSpec, AgentRunner
provider = MagicMock()
provider.supports_progress_deltas = True
emitted_reasoning: list[str] = []
async def chat_stream_with_retry(*, on_content_delta=None, **kwargs):
if on_content_delta:
await on_content_delta("The ")
await on_content_delta("answer.")
return LLMResponse(
content="The answer.",
reasoning_content="step-by-step deduction",
tool_calls=[],
usage={"prompt_tokens": 5, "completion_tokens": 3},
)
provider.chat_stream_with_retry = chat_stream_with_retry
tools = MagicMock()
tools.get_definitions.return_value = []
class ReasoningHook(AgentHook):
async def emit_reasoning(self, reasoning_content: str | None) -> None:
if reasoning_content:
emitted_reasoning.append(reasoning_content)
progress_calls: list[str] = []
async def _progress(content: str, **_kwargs):
progress_calls.append(content)
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
initial_messages=[{"role": "user", "content": "question"}],
tools=tools,
model="test-model",
max_iterations=3,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
hook=ReasoningHook(),
stream_progress_deltas=True,
progress_callback=_progress,
))
assert result.final_content == "The answer."
# The answer must have streamed AND the dedicated reasoning_content must
# have been emitted exactly once after the stream completed.
assert progress_calls, "answer should have streamed via progress callback"
assert emitted_reasoning == ["step-by-step deduction"]
@pytest.mark.asyncio
async def test_runner_does_not_double_emit_when_inline_think_already_streamed():
"""Inline `<think>` blocks streamed incrementally during the answer
stream must not be re-emitted from the final response."""
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.runner import AgentRunSpec, AgentRunner
provider = MagicMock()
provider.supports_progress_deltas = True
emitted_reasoning: list[str] = []
async def chat_stream_with_retry(*, on_content_delta=None, **kwargs):
if on_content_delta:
await on_content_delta("<think>working...</think>")
await on_content_delta("The answer.")
return LLMResponse(
content="<think>working...</think>The answer.",
tool_calls=[],
usage={"prompt_tokens": 5, "completion_tokens": 3},
)
provider.chat_stream_with_retry = chat_stream_with_retry
tools = MagicMock()
tools.get_definitions.return_value = []
class ReasoningHook(AgentHook):
async def emit_reasoning(self, reasoning_content: str | None) -> None:
if reasoning_content:
emitted_reasoning.append(reasoning_content)
async def _progress(content: str, **_kwargs):
pass
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
initial_messages=[{"role": "user", "content": "question"}],
tools=tools,
model="test-model",
max_iterations=3,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
hook=ReasoningHook(),
stream_progress_deltas=True,
progress_callback=_progress,
))
assert result.final_content == "The answer."
assert emitted_reasoning == ["working..."]
@pytest.mark.asyncio
async def test_runner_calls_hooks_in_order():
from nanobot.agent.hook import AgentHook, AgentHookContext