From 715b2db24b312cd81d6601651cc17f7a523d722b Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 18 Feb 2026 14:23:51 +0000 Subject: [PATCH] feat: stream intermediate progress to user during tool execution --- README.md | 2 +- nanobot/agent/context.py | 2 +- nanobot/agent/loop.py | 57 +++++++++++++++++++++++++++++++++++----- nanobot/cli/commands.py | 7 +++-- 4 files changed, 57 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index dfe799a..fcbc878 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines. -📏 Real-time line count: **3,696 lines** (run `bash core_agent_lines.sh` to verify anytime) +📏 Real-time line count: **3,761 lines** (run `bash core_agent_lines.sh` to verify anytime) ## 📢 News diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index cfd6318..876d43d 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -105,7 +105,7 @@ IMPORTANT: When responding to direct questions or conversations, reply directly Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp). For normal conversation, just respond with text - do not call the message tool. -Always be helpful, accurate, and concise. When using tools, think step by step: what you know, what you need, and why you chose this tool. +Always be helpful, accurate, and concise. Before calling tools, briefly tell the user what you're about to do (one short sentence in the user's language). When remembering something important, write to {workspace_path}/memory/MEMORY.md To recall past events, grep {workspace_path}/memory/HISTORY.md""" diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 6342f56..e5a5183 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -5,7 +5,8 @@ from contextlib import AsyncExitStack import json import json_repair from pathlib import Path -from typing import Any +import re +from typing import Any, Awaitable, Callable from loguru import logger @@ -146,12 +147,34 @@ class AgentLoop: if isinstance(cron_tool, CronTool): cron_tool.set_context(channel, chat_id) - async def _run_agent_loop(self, initial_messages: list[dict]) -> tuple[str | None, list[str]]: + @staticmethod + def _strip_think(text: str | None) -> str | None: + """Remove blocks that some models embed in content.""" + if not text: + return None + return re.sub(r"[\s\S]*?", "", text).strip() or None + + @staticmethod + def _tool_hint(tool_calls: list) -> str: + """Format tool calls as concise hint, e.g. 'web_search("query")'.""" + def _fmt(tc): + val = next(iter(tc.arguments.values()), None) if tc.arguments else None + if not isinstance(val, str): + return tc.name + return f'{tc.name}("{val[:40]}…")' if len(val) > 40 else f'{tc.name}("{val}")' + return ", ".join(_fmt(tc) for tc in tool_calls) + + async def _run_agent_loop( + self, + initial_messages: list[dict], + on_progress: Callable[[str], Awaitable[None]] | None = None, + ) -> tuple[str | None, list[str]]: """ Run the agent iteration loop. Args: initial_messages: Starting messages for the LLM conversation. + on_progress: Optional callback to push intermediate content to the user. Returns: Tuple of (final_content, list_of_tools_used). @@ -173,6 +196,10 @@ class AgentLoop: ) if response.has_tool_calls: + if on_progress: + clean = self._strip_think(response.content) + await on_progress(clean or self._tool_hint(response.tool_calls)) + tool_call_dicts = [ { "id": tc.id, @@ -197,9 +224,8 @@ class AgentLoop: messages = self.context.add_tool_result( messages, tool_call.id, tool_call.name, result ) - messages.append({"role": "user", "content": "Reflect on the results and decide next steps."}) else: - final_content = response.content + final_content = self._strip_think(response.content) break return final_content, tools_used @@ -244,13 +270,19 @@ class AgentLoop: self._running = False logger.info("Agent loop stopping") - async def _process_message(self, msg: InboundMessage, session_key: str | None = None) -> OutboundMessage | None: + async def _process_message( + self, + msg: InboundMessage, + session_key: str | None = None, + on_progress: Callable[[str], Awaitable[None]] | None = None, + ) -> OutboundMessage | None: """ Process a single inbound message. Args: msg: The inbound message to process. session_key: Override session key (used by process_direct). + on_progress: Optional callback for intermediate output (defaults to bus publish). Returns: The response message, or None if no response needed. @@ -297,7 +329,16 @@ class AgentLoop: channel=msg.channel, chat_id=msg.chat_id, ) - final_content, tools_used = await self._run_agent_loop(initial_messages) + + async def _bus_progress(content: str) -> None: + await self.bus.publish_outbound(OutboundMessage( + channel=msg.channel, chat_id=msg.chat_id, content=content, + metadata=msg.metadata or {}, + )) + + final_content, tools_used = await self._run_agent_loop( + initial_messages, on_progress=on_progress or _bus_progress, + ) if final_content is None: final_content = "I've completed processing but have no response to give." @@ -451,6 +492,7 @@ Respond with ONLY valid JSON, no markdown fences.""" session_key: str = "cli:direct", channel: str = "cli", chat_id: str = "direct", + on_progress: Callable[[str], Awaitable[None]] | None = None, ) -> str: """ Process a message directly (for CLI or cron usage). @@ -460,6 +502,7 @@ Respond with ONLY valid JSON, no markdown fences.""" session_key: Session identifier (overrides channel:chat_id for session lookup). channel: Source channel (for tool context routing). chat_id: Source chat ID (for tool context routing). + on_progress: Optional callback for intermediate output. Returns: The agent's response. @@ -472,5 +515,5 @@ Respond with ONLY valid JSON, no markdown fences.""" content=content ) - response = await self._process_message(msg, session_key=session_key) + response = await self._process_message(msg, session_key=session_key, on_progress=on_progress) return response.content if response else "" diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 8e17139..2f4ba7b 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -494,11 +494,14 @@ def agent( # Animated spinner is safe to use with prompt_toolkit input handling return console.status("[dim]nanobot is thinking...[/dim]", spinner="dots") + async def _cli_progress(content: str) -> None: + console.print(f" [dim]↳ {content}[/dim]") + if message: # Single message mode async def run_once(): with _thinking_ctx(): - response = await agent_loop.process_direct(message, session_id) + response = await agent_loop.process_direct(message, session_id, on_progress=_cli_progress) _print_agent_response(response, render_markdown=markdown) await agent_loop.close_mcp() @@ -531,7 +534,7 @@ def agent( break with _thinking_ctx(): - response = await agent_loop.process_direct(user_input, session_id) + response = await agent_loop.process_direct(user_input, session_id, on_progress=_cli_progress) _print_agent_response(response, render_markdown=markdown) except KeyboardInterrupt: _restore_terminal()