diff --git a/nanobot/agent/hook.py b/nanobot/agent/hook.py index d0106cfb6..5e4ea4d4d 100644 --- a/nanobot/agent/hook.py +++ b/nanobot/agent/hook.py @@ -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) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 330c82357..e12bf53c9 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -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( diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index 7fe92ad51..2ff2cf045 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -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) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 236d787ce..1c835962a 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -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: diff --git a/nanobot/cli/stream.py b/nanobot/cli/stream.py index 807c88fef..ec7f0a96c 100644 --- a/nanobot/cli/stream.py +++ b/nanobot/cli/stream.py @@ -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()) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 47f2babcd..66a7a75aa 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -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 diff --git a/tests/agent/test_hook_composite.py b/tests/agent/test_hook_composite.py index 8971d48ec..9b6c2820d 100644 --- a/tests/agent/test_hook_composite.py +++ b/tests/agent/test_hook_composite.py @@ -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"] # --------------------------------------------------------------------------- diff --git a/tests/cli/test_cli_input.py b/tests/cli/test_cli_input.py index e648e818c..69293f4b8 100644 --- a/tests/cli/test_cli_input.py +++ b/tests/cli/test_cli_input.py @@ -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 diff --git a/tests/cli/test_interactive_retry_wait.py b/tests/cli/test_interactive_retry_wait.py index e58102dcd..e693b057c 100644 --- a/tests/cli/test_interactive_retry_wait.py +++ b/tests/cli/test_interactive_retry_wait.py @@ -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..."]