mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-19 16:12:30 +00:00
feat(cli): display model reasoning content during streaming
Add show_reasoning config (default: False) to display model thinking/reasoning content in the TUI during streaming. Reasoning is emitted via a new emit_reasoning hook on AgentHook, gated by the channels config. Display uses ✻ prefix with dim italic styling. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
d630ac90d1
commit
3a27af0018
@ -48,6 +48,9 @@ class AgentHook:
|
||||
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
||||
pass
|
||||
|
||||
async def emit_reasoning(self, reasoning_content: str | None) -> None:
|
||||
pass
|
||||
|
||||
async def after_iteration(self, context: AgentHookContext) -> None:
|
||||
pass
|
||||
|
||||
@ -95,6 +98,9 @@ class CompositeHook(AgentHook):
|
||||
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
||||
await self._for_each_hook_safe("before_execute_tools", context)
|
||||
|
||||
async def emit_reasoning(self, reasoning_content: str | None) -> None:
|
||||
await self._for_each_hook_safe("emit_reasoning", reasoning_content)
|
||||
|
||||
async def after_iteration(self, context: AgentHookContext) -> None:
|
||||
await self._for_each_hook_safe("after_iteration", context)
|
||||
|
||||
|
||||
@ -155,6 +155,14 @@ class _LoopHook(AgentHook):
|
||||
session_key=self._session_key,
|
||||
)
|
||||
|
||||
async def emit_reasoning(self, reasoning_content: str | None) -> None:
|
||||
"""Send reasoning/thinking content as progress before the main answer."""
|
||||
ch = self._loop.channels_config
|
||||
if not ch or not ch.show_reasoning:
|
||||
return
|
||||
if self._on_progress and reasoning_content:
|
||||
await self._on_progress(reasoning_content, reasoning=True)
|
||||
|
||||
async def after_iteration(self, context: AgentHookContext) -> None:
|
||||
if (
|
||||
self._on_progress
|
||||
@ -1114,10 +1122,13 @@ class AgentLoop:
|
||||
*,
|
||||
tool_hint: bool = False,
|
||||
tool_events: list[dict[str, Any]] | None = None,
|
||||
reasoning: bool = False,
|
||||
) -> None:
|
||||
meta = dict(msg.metadata or {})
|
||||
meta["_progress"] = True
|
||||
meta["_tool_hint"] = tool_hint
|
||||
if reasoning:
|
||||
meta["_reasoning"] = True
|
||||
if tool_events:
|
||||
meta["_tool_events"] = tool_events
|
||||
await self.bus.publish_outbound(
|
||||
|
||||
@ -282,6 +282,9 @@ class AgentRunner:
|
||||
context.tool_calls = list(response.tool_calls)
|
||||
self._accumulate_usage(usage, raw_usage)
|
||||
|
||||
if response.reasoning_content:
|
||||
await hook.emit_reasoning(response.reasoning_content)
|
||||
|
||||
if response.should_execute_tools:
|
||||
tool_calls = list(response.tool_calls)
|
||||
ask_index = next((i for i, tc in enumerate(tool_calls) if tc.name == "ask_user"), None)
|
||||
|
||||
@ -237,6 +237,16 @@ def _print_cli_progress_line(text: str, thinking: ThinkingSpinner | None, render
|
||||
target.print(f" [dim]↳ {text}[/dim]")
|
||||
|
||||
|
||||
def _print_cli_reasoning(text: str, thinking: ThinkingSpinner | None, renderer: StreamRenderer | None = None) -> None:
|
||||
"""Print reasoning/thinking content in a distinct style."""
|
||||
if not text.strip():
|
||||
return
|
||||
target = renderer.console if renderer else console
|
||||
pause = renderer.pause_spinner() if renderer else (thinking.pause() if thinking else nullcontext())
|
||||
with pause:
|
||||
target.print(f"[dim italic]✻ {text}[/dim italic]")
|
||||
|
||||
|
||||
async def _print_interactive_progress_line(text: str, thinking: ThinkingSpinner | None, renderer: StreamRenderer | None = None) -> None:
|
||||
"""Print an interactive progress line, pausing the spinner if needed."""
|
||||
if not text.strip():
|
||||
@ -264,12 +274,18 @@ async def _maybe_print_interactive_progress(
|
||||
return False
|
||||
|
||||
is_tool_hint = metadata.get("_tool_hint", False)
|
||||
is_reasoning = metadata.get("_reasoning", False)
|
||||
if channels_config and is_tool_hint and not channels_config.send_tool_hints:
|
||||
return True
|
||||
if channels_config and not is_tool_hint and not channels_config.send_progress:
|
||||
return True
|
||||
if is_reasoning and channels_config and not channels_config.show_reasoning:
|
||||
return True
|
||||
|
||||
await _print_interactive_progress_line(msg.content, thinking, renderer)
|
||||
if is_reasoning:
|
||||
_print_cli_reasoning(msg.content, thinking, renderer)
|
||||
else:
|
||||
await _print_interactive_progress_line(msg.content, thinking, renderer)
|
||||
return True
|
||||
|
||||
|
||||
@ -1129,13 +1145,18 @@ def agent(
|
||||
_thinking: ThinkingSpinner | None = None
|
||||
|
||||
def _make_progress(renderer: StreamRenderer | None = None):
|
||||
async def _cli_progress(content: str, *, tool_hint: bool = False, **_kwargs: Any) -> None:
|
||||
async def _cli_progress(content: str, *, tool_hint: bool = False, reasoning: bool = False, **_kwargs: Any) -> None:
|
||||
ch = agent_loop.channels_config
|
||||
if ch and tool_hint and not ch.send_tool_hints:
|
||||
return
|
||||
if ch and not tool_hint and not ch.send_progress:
|
||||
return
|
||||
_print_cli_progress_line(content, _thinking, renderer)
|
||||
if reasoning and ch and not ch.show_reasoning:
|
||||
return
|
||||
if reasoning:
|
||||
_print_cli_reasoning(content, _thinking, renderer)
|
||||
else:
|
||||
_print_cli_progress_line(content, _thinking, renderer)
|
||||
return _cli_progress
|
||||
|
||||
if message:
|
||||
|
||||
@ -84,7 +84,6 @@ class StreamRenderer:
|
||||
self._show_spinner = show_spinner
|
||||
self._buf = ""
|
||||
self.streamed = False
|
||||
self._header_printed = False
|
||||
self._console = _make_console()
|
||||
self._live: Live | None = None
|
||||
self._spinner: ThinkingSpinner | None = None
|
||||
@ -127,12 +126,12 @@ class StreamRenderer:
|
||||
async def on_delta(self, delta: str) -> None:
|
||||
self.streamed = True
|
||||
self._buf += delta
|
||||
if not self._header_printed and self._buf.strip():
|
||||
if self._live is None:
|
||||
if not self._buf.strip():
|
||||
return
|
||||
self._stop_spinner()
|
||||
self._console.print()
|
||||
self._console.print(f"[cyan]{__logo__} nanobot[/cyan]")
|
||||
self._header_printed = True
|
||||
self._stop_spinner()
|
||||
if not self._live:
|
||||
self._live = Live(
|
||||
self._renderable(),
|
||||
console=self._console,
|
||||
@ -153,7 +152,7 @@ class StreamRenderer:
|
||||
self._live.stop()
|
||||
self._live = None
|
||||
self._stop_spinner()
|
||||
if self._header_printed and self._buf.strip():
|
||||
if self._buf.strip():
|
||||
# Print final rendered content (persists after Live is gone).
|
||||
out = sys.stdout
|
||||
out.write(self._render_str())
|
||||
|
||||
@ -27,6 +27,7 @@ class ChannelsConfig(Base):
|
||||
|
||||
send_progress: bool = True # stream agent's text progress to the channel
|
||||
send_tool_hints: bool = False # stream tool-call hints (e.g. read_file("…"))
|
||||
show_reasoning: bool = False # show model reasoning/thinking content
|
||||
send_max_retries: int = Field(default=3, ge=0, le=10) # Max delivery attempts (initial send included)
|
||||
transcription_provider: str = "groq" # Voice transcription backend: "groq" or "openai"
|
||||
transcription_language: str | None = Field(default=None, pattern=r"^[a-z]{2,3}$") # Optional ISO-639-1 hint for audio transcription
|
||||
|
||||
@ -13,6 +13,17 @@ def _ctx() -> AgentHookContext:
|
||||
return AgentHookContext(iteration=0, messages=[])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Base AgentHook emit_reasoning: no-op
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_base_hook_emit_reasoning_is_noop():
|
||||
hook = AgentHook()
|
||||
await hook.emit_reasoning("should not raise")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fan-out: every hook is called in order
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -45,6 +56,9 @@ async def test_composite_fans_out_all_async_methods():
|
||||
async def before_iteration(self, context: AgentHookContext) -> None:
|
||||
events.append("before_iteration")
|
||||
|
||||
async def emit_reasoning(self, reasoning_content: str | None) -> None:
|
||||
events.append(f"emit_reasoning:{reasoning_content}")
|
||||
|
||||
async def on_stream(self, context: AgentHookContext, delta: str) -> None:
|
||||
events.append(f"on_stream:{delta}")
|
||||
|
||||
@ -61,6 +75,7 @@ async def test_composite_fans_out_all_async_methods():
|
||||
ctx = _ctx()
|
||||
|
||||
await hook.before_iteration(ctx)
|
||||
await hook.emit_reasoning("thinking...")
|
||||
await hook.on_stream(ctx, "hi")
|
||||
await hook.on_stream_end(ctx, resuming=True)
|
||||
await hook.before_execute_tools(ctx)
|
||||
@ -68,6 +83,7 @@ async def test_composite_fans_out_all_async_methods():
|
||||
|
||||
assert events == [
|
||||
"before_iteration", "before_iteration",
|
||||
"emit_reasoning:thinking...", "emit_reasoning:thinking...",
|
||||
"on_stream:hi", "on_stream:hi",
|
||||
"on_stream_end:True", "on_stream_end:True",
|
||||
"before_execute_tools", "before_execute_tools",
|
||||
@ -120,6 +136,8 @@ async def test_composite_error_isolation_all_async():
|
||||
calls: list[str] = []
|
||||
|
||||
class Bad(AgentHook):
|
||||
async def emit_reasoning(self, reasoning_content):
|
||||
raise RuntimeError("err")
|
||||
async def on_stream_end(self, context, *, resuming):
|
||||
raise RuntimeError("err")
|
||||
async def before_execute_tools(self, context):
|
||||
@ -128,6 +146,8 @@ async def test_composite_error_isolation_all_async():
|
||||
raise RuntimeError("err")
|
||||
|
||||
class Good(AgentHook):
|
||||
async def emit_reasoning(self, reasoning_content):
|
||||
calls.append("emit_reasoning")
|
||||
async def on_stream_end(self, context, *, resuming):
|
||||
calls.append("on_stream_end")
|
||||
async def before_execute_tools(self, context):
|
||||
@ -137,10 +157,11 @@ async def test_composite_error_isolation_all_async():
|
||||
|
||||
hook = CompositeHook([Bad(), Good()])
|
||||
ctx = _ctx()
|
||||
await hook.emit_reasoning("test")
|
||||
await hook.on_stream_end(ctx, resuming=False)
|
||||
await hook.before_execute_tools(ctx)
|
||||
await hook.after_iteration(ctx)
|
||||
assert calls == ["on_stream_end", "before_execute_tools", "after_iteration"]
|
||||
assert calls == ["emit_reasoning", "on_stream_end", "before_execute_tools", "after_iteration"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@ -156,17 +156,65 @@ def test_stream_renderer_stop_for_input_stops_spinner():
|
||||
# Create renderer with mocked console
|
||||
with patch.object(stream_mod, "_make_console", return_value=mock_console):
|
||||
renderer = stream_mod.StreamRenderer(show_spinner=True)
|
||||
|
||||
|
||||
# Verify spinner started
|
||||
spinner.start.assert_called_once()
|
||||
|
||||
|
||||
# Stop for input
|
||||
renderer.stop_for_input()
|
||||
|
||||
|
||||
# Verify spinner stopped
|
||||
spinner.stop.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_end_writes_final_content_to_stdout_after_stopping_live():
|
||||
"""on_end should stop Live (transient erases it) then print final content to stdout."""
|
||||
mock_live = MagicMock()
|
||||
mock_console = MagicMock()
|
||||
mock_console.capture.return_value.__enter__ = MagicMock(
|
||||
return_value=MagicMock(get=lambda: "final output\n")
|
||||
)
|
||||
mock_console.capture.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch.object(stream_mod, "_make_console", return_value=mock_console):
|
||||
renderer = stream_mod.StreamRenderer(show_spinner=False)
|
||||
renderer._live = mock_live
|
||||
renderer._buf = "final output"
|
||||
|
||||
written: list[str] = []
|
||||
with patch("sys.stdout") as mock_stdout:
|
||||
mock_stdout.write = lambda s: written.append(s)
|
||||
mock_stdout.flush = MagicMock()
|
||||
await renderer.on_end()
|
||||
|
||||
mock_live.stop.assert_called_once()
|
||||
assert renderer._live is None
|
||||
assert written == ["final output\n"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_end_resuming_clears_buffer_and_restarts_spinner():
|
||||
"""on_end(resuming=True) should reset state for the next iteration."""
|
||||
spinner = MagicMock()
|
||||
mock_console = MagicMock()
|
||||
mock_console.status.return_value = spinner
|
||||
mock_console.capture.return_value.__enter__ = MagicMock(
|
||||
return_value=MagicMock(get=lambda: "")
|
||||
)
|
||||
mock_console.capture.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch.object(stream_mod, "_make_console", return_value=mock_console):
|
||||
renderer = stream_mod.StreamRenderer(show_spinner=True)
|
||||
renderer._buf = "some content"
|
||||
|
||||
await renderer.on_end(resuming=True)
|
||||
|
||||
assert renderer._buf == ""
|
||||
# Spinner should have been restarted (start called twice: __init__ + resuming)
|
||||
assert spinner.start.call_count == 2
|
||||
|
||||
|
||||
def test_make_console_force_terminal_when_stdout_is_tty():
|
||||
"""Console should set force_terminal=True when stdout is a TTY (rich output)."""
|
||||
import sys
|
||||
|
||||
@ -29,3 +29,62 @@ async def test_interactive_retry_wait_is_rendered_as_progress_even_when_progress
|
||||
|
||||
assert handled is True
|
||||
assert calls == [("Model request failed, retry in 2s (attempt 1).", thinking)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reasoning_displayed_when_show_reasoning_enabled():
|
||||
"""Reasoning content should be displayed when show_reasoning is True."""
|
||||
calls: list[str] = []
|
||||
channels_config = SimpleNamespace(
|
||||
send_progress=True, send_tool_hints=False, show_reasoning=True,
|
||||
)
|
||||
msg = SimpleNamespace(
|
||||
content="Let me think about this...",
|
||||
metadata={"_progress": True, "_reasoning": True},
|
||||
)
|
||||
|
||||
with patch("nanobot.cli.commands._print_cli_reasoning", side_effect=lambda t, th, r=None: calls.append(t)):
|
||||
handled = await commands._maybe_print_interactive_progress(msg, None, channels_config)
|
||||
|
||||
assert handled is True
|
||||
assert calls == ["Let me think about this..."]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reasoning_hidden_when_show_reasoning_disabled():
|
||||
"""Reasoning content should be suppressed when show_reasoning is False."""
|
||||
channels_config = SimpleNamespace(
|
||||
send_progress=True, send_tool_hints=False, show_reasoning=False,
|
||||
)
|
||||
msg = SimpleNamespace(
|
||||
content="Let me think about this...",
|
||||
metadata={"_progress": True, "_reasoning": True},
|
||||
)
|
||||
|
||||
with patch("nanobot.cli.commands._print_cli_reasoning") as mock_reasoning:
|
||||
handled = await commands._maybe_print_interactive_progress(msg, None, channels_config)
|
||||
|
||||
assert handled is True
|
||||
mock_reasoning.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_reasoning_progress_not_affected_by_show_reasoning():
|
||||
"""Regular progress lines should display regardless of show_reasoning."""
|
||||
calls: list[str] = []
|
||||
channels_config = SimpleNamespace(
|
||||
send_progress=True, send_tool_hints=False, show_reasoning=False,
|
||||
)
|
||||
msg = SimpleNamespace(
|
||||
content="working on it...",
|
||||
metadata={"_progress": True},
|
||||
)
|
||||
|
||||
async def fake_print(text: str, thinking=None, renderer=None):
|
||||
calls.append(text)
|
||||
|
||||
with patch("nanobot.cli.commands._print_interactive_progress_line", side_effect=fake_print):
|
||||
handled = await commands._maybe_print_interactive_progress(msg, None, channels_config)
|
||||
|
||||
assert handled is True
|
||||
assert calls == ["working on it..."]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user