From afca0278ad9e99be7f2778fdbdcb1bb3aac2ecb0 Mon Sep 17 00:00:00 2001 From: Rudolfs Tilgass Date: Thu, 19 Feb 2026 21:02:52 +0100 Subject: [PATCH 01/46] fix(memory): Enforce memory consolidation schema with a tool call --- nanobot/agent/loop.py | 111 +++++++++++++++++++++++------------------- 1 file changed, 62 insertions(+), 49 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index e5a5183..e9e225c 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -3,7 +3,6 @@ import asyncio from contextlib import AsyncExitStack import json -import json_repair from pathlib import Path import re from typing import Any, Awaitable, Callable @@ -84,13 +83,13 @@ class AgentLoop: exec_config=self.exec_config, restrict_to_workspace=restrict_to_workspace, ) - + self._running = False self._mcp_servers = mcp_servers or {} self._mcp_stack: AsyncExitStack | None = None self._mcp_connected = False self._register_default_tools() - + def _register_default_tools(self) -> None: """Register the default set of tools.""" # File tools (restrict to workspace if configured) @@ -99,30 +98,30 @@ class AgentLoop: self.tools.register(WriteFileTool(allowed_dir=allowed_dir)) self.tools.register(EditFileTool(allowed_dir=allowed_dir)) self.tools.register(ListDirTool(allowed_dir=allowed_dir)) - + # Shell tool self.tools.register(ExecTool( working_dir=str(self.workspace), timeout=self.exec_config.timeout, restrict_to_workspace=self.restrict_to_workspace, )) - + # Web tools self.tools.register(WebSearchTool(api_key=self.brave_api_key)) self.tools.register(WebFetchTool()) - + # Message tool message_tool = MessageTool(send_callback=self.bus.publish_outbound) self.tools.register(message_tool) - + # Spawn tool (for subagents) spawn_tool = SpawnTool(manager=self.subagents) self.tools.register(spawn_tool) - + # Cron tool (for scheduling) if self.cron_service: self.tools.register(CronTool(self.cron_service)) - + async def _connect_mcp(self) -> None: """Connect to configured MCP servers (one-time, lazy).""" if self._mcp_connected or not self._mcp_servers: @@ -255,7 +254,7 @@ class AgentLoop: )) except asyncio.TimeoutError: continue - + async def close_mcp(self) -> None: """Close MCP connections.""" if self._mcp_stack: @@ -269,7 +268,7 @@ class AgentLoop: """Stop the agent loop.""" self._running = False logger.info("Agent loop stopping") - + async def _process_message( self, msg: InboundMessage, @@ -278,25 +277,25 @@ class AgentLoop: ) -> 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. """ # System messages route back via chat_id ("channel:chat_id") if msg.channel == "system": return await self._process_system_message(msg) - + preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content logger.info(f"Processing message from {msg.channel}:{msg.sender_id}: {preview}") - + key = session_key or msg.session_key session = self.sessions.get_or_create(key) - + # Handle slash commands cmd = msg.content.strip().lower() if cmd == "/new": @@ -317,7 +316,7 @@ class AgentLoop: if cmd == "/help": return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content="🐈 nanobot commands:\n/new β€” Start a new conversation\n/help β€” Show available commands") - + if len(session.messages) > self.memory_window: asyncio.create_task(self._consolidate_memory(session)) @@ -342,31 +341,31 @@ class AgentLoop: if final_content is None: final_content = "I've completed processing but have no response to give." - + preview = final_content[:120] + "..." if len(final_content) > 120 else final_content logger.info(f"Response to {msg.channel}:{msg.sender_id}: {preview}") - + session.add_message("user", msg.content) session.add_message("assistant", final_content, tools_used=tools_used if tools_used else None) self.sessions.save(session) - + return OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content=final_content, metadata=msg.metadata or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts) ) - + async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None: """ Process a system message (e.g., subagent announce). - + The chat_id field contains "original_channel:original_chat_id" to route the response back to the correct destination. """ logger.info(f"Processing system message from {msg.sender_id}") - + # Parse origin from chat_id (format: "channel:chat_id") if ":" in msg.chat_id: parts = msg.chat_id.split(":", 1) @@ -376,7 +375,7 @@ class AgentLoop: # Fallback origin_channel = "cli" origin_chat_id = msg.chat_id - + session_key = f"{origin_channel}:{origin_chat_id}" session = self.sessions.get_or_create(session_key) self._set_tool_context(origin_channel, origin_chat_id) @@ -390,17 +389,17 @@ class AgentLoop: if final_content is None: final_content = "Background task completed." - + session.add_message("user", f"[System: {msg.sender_id}] {msg.content}") session.add_message("assistant", final_content) self.sessions.save(session) - + return OutboundMessage( channel=origin_channel, chat_id=origin_chat_id, content=final_content ) - + async def _consolidate_memory(self, session, archive_all: bool = False) -> None: """Consolidate old messages into MEMORY.md + HISTORY.md. @@ -439,42 +438,56 @@ class AgentLoop: conversation = "\n".join(lines) current_memory = memory.read_long_term() - prompt = f"""You are a memory consolidation agent. Process this conversation and return a JSON object with exactly two keys: - -1. "history_entry": A paragraph (2-5 sentences) summarizing the key events/decisions/topics. Start with a timestamp like [YYYY-MM-DD HH:MM]. Include enough detail to be useful when found by grep search later. - -2. "memory_update": The updated long-term memory content. Add any new facts: user location, preferences, personal info, habits, project context, technical decisions, tools/services used. If nothing new, return the existing content unchanged. + prompt = f"""Process this conversation and call the save_memory tool with your consolidation. ## Current Long-term Memory {current_memory or "(empty)"} ## Conversation to Process -{conversation} +{conversation}""" -Respond with ONLY valid JSON, no markdown fences.""" + save_memory_tool = [ + { + "type": "function", + "function": { + "name": "save_memory", + "description": "Save the memory consolidation result to persistent storage.", + "parameters": { + "type": "object", + "properties": { + "history_entry": { + "type": "string", + "description": "A paragraph (2-5 sentences) summarizing key events/decisions/topics. Start with a timestamp like [YYYY-MM-DD HH:MM]. Include enough detail to be useful when found by grep search later.", + }, + "memory_update": { + "type": "string", + "description": "The full updated long-term memory content as a markdown string. Include all existing facts plus any new facts: user location, preferences, personal info, habits, project context, technical decisions, tools/services used. If nothing new, return the existing content unchanged.", + }, + }, + "required": ["history_entry", "memory_update"], + }, + }, + } + ] try: response = await self.provider.chat( messages=[ - {"role": "system", "content": "You are a memory consolidation agent. Respond only with valid JSON."}, + {"role": "system", "content": "You are a memory consolidation agent. Call the save_memory tool with your consolidation of the conversation."}, {"role": "user", "content": prompt}, ], + tools=save_memory_tool, model=self.model, ) - text = (response.content or "").strip() - if not text: - logger.warning("Memory consolidation: LLM returned empty response, skipping") - return - if text.startswith("```"): - text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip() - result = json_repair.loads(text) - if not isinstance(result, dict): - logger.warning(f"Memory consolidation: unexpected response type, skipping. Response: {text[:200]}") + + if not response.has_tool_calls: + logger.warning("Memory consolidation: LLM did not call save_memory tool, skipping") return - if entry := result.get("history_entry"): + args = response.tool_calls[0].arguments + if entry := args.get("history_entry"): memory.append_history(entry) - if update := result.get("memory_update"): + if update := args.get("memory_update"): if update != current_memory: memory.write_long_term(update) @@ -496,14 +509,14 @@ Respond with ONLY valid JSON, no markdown fences.""" ) -> str: """ Process a message directly (for CLI or cron usage). - + Args: content: The message content. 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. """ @@ -514,6 +527,6 @@ Respond with ONLY valid JSON, no markdown fences.""" chat_id=chat_id, content=content ) - + response = await self._process_message(msg, session_key=session_key, on_progress=on_progress) return response.content if response else "" From e39bbaa9be85e57020be9735051e5f8044f53ed1 Mon Sep 17 00:00:00 2001 From: Paul Date: Fri, 20 Feb 2026 09:54:21 +0000 Subject: [PATCH 02/46] feat(slack): add media file upload support Use files_upload_v2 API to upload media attachments in Slack messages. This enables the message tool's media parameter to work correctly when sending images or other files through the Slack channel. Requires files:write OAuth scope. --- nanobot/channels/slack.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index dca5055..d29f1e1 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -84,11 +84,26 @@ class SlackChannel(BaseChannel): channel_type = slack_meta.get("channel_type") # Only reply in thread for channel/group messages; DMs don't use threads use_thread = thread_ts and channel_type != "im" - await self._web_client.chat_postMessage( - channel=msg.chat_id, - text=self._to_mrkdwn(msg.content), - thread_ts=thread_ts if use_thread else None, - ) + thread_ts_param = thread_ts if use_thread else None + + # Send text message if content is present + if msg.content: + await self._web_client.chat_postMessage( + channel=msg.chat_id, + text=self._to_mrkdwn(msg.content), + thread_ts=thread_ts_param, + ) + + # Upload media files if present + for media_path in msg.media or []: + try: + await self._web_client.files_upload_v2( + channel=msg.chat_id, + file=media_path, + thread_ts=thread_ts_param, + ) + except Exception as e: + logger.error(f"Failed to upload file {media_path}: {e}") except Exception as e: logger.error(f"Error sending Slack message: {e}") From ddae3e9d5f73ab9d95aa866d9894190d0f2200de Mon Sep 17 00:00:00 2001 From: Kim <150593189+KimGLee@users.noreply.github.com> Date: Thu, 19 Feb 2026 11:25:47 +0800 Subject: [PATCH 03/46] fix(agent): avoid duplicate final send when message tool already replied --- nanobot/agent/loop.py | 120 +++++++++++++++++++-------------- nanobot/agent/tools/message.py | 43 +++++++----- 2 files changed, 97 insertions(+), 66 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 3016d92..926fad9 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -1,30 +1,36 @@ """Agent loop: the core processing engine.""" -import asyncio -from contextlib import AsyncExitStack -import json -import json_repair -from pathlib import Path -import re -from typing import Any, Awaitable, Callable +from __future__ import annotations +import asyncio +import json +import re +from contextlib import AsyncExitStack +from pathlib import Path +from typing import TYPE_CHECKING, Awaitable, Callable + +import json_repair from loguru import logger +from nanobot.agent.context import ContextBuilder +from nanobot.agent.memory import MemoryStore +from nanobot.agent.subagent import SubagentManager +from nanobot.agent.tools.cron import CronTool +from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool +from nanobot.agent.tools.message import MessageTool +from nanobot.agent.tools.registry import ToolRegistry +from nanobot.agent.tools.shell import ExecTool +from nanobot.agent.tools.spawn import SpawnTool +from nanobot.agent.tools.web import WebFetchTool, WebSearchTool from nanobot.bus.events import InboundMessage, OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.providers.base import LLMProvider -from nanobot.agent.context import ContextBuilder -from nanobot.agent.tools.registry import ToolRegistry -from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool -from nanobot.agent.tools.shell import ExecTool -from nanobot.agent.tools.web import WebSearchTool, WebFetchTool -from nanobot.agent.tools.message import MessageTool -from nanobot.agent.tools.spawn import SpawnTool -from nanobot.agent.tools.cron import CronTool -from nanobot.agent.memory import MemoryStore -from nanobot.agent.subagent import SubagentManager from nanobot.session.manager import Session, SessionManager +if TYPE_CHECKING: + from nanobot.config.schema import ExecToolConfig + from nanobot.cron.service import CronService + class AgentLoop: """ @@ -49,14 +55,13 @@ class AgentLoop: max_tokens: int = 4096, memory_window: int = 50, brave_api_key: str | None = None, - exec_config: "ExecToolConfig | None" = None, - cron_service: "CronService | None" = None, + exec_config: ExecToolConfig | None = None, + cron_service: CronService | None = None, restrict_to_workspace: bool = False, session_manager: SessionManager | None = None, mcp_servers: dict | None = None, ): from nanobot.config.schema import ExecToolConfig - from nanobot.cron.service import CronService self.bus = bus self.provider = provider self.workspace = workspace @@ -84,14 +89,14 @@ class AgentLoop: exec_config=self.exec_config, restrict_to_workspace=restrict_to_workspace, ) - + self._running = False self._mcp_servers = mcp_servers or {} self._mcp_stack: AsyncExitStack | None = None self._mcp_connected = False self._consolidating: set[str] = set() # Session keys with consolidation in progress self._register_default_tools() - + def _register_default_tools(self) -> None: """Register the default set of tools.""" # File tools (workspace for relative paths, restrict if configured) @@ -100,30 +105,30 @@ class AgentLoop: self.tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) self.tools.register(EditFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) self.tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir)) - + # Shell tool self.tools.register(ExecTool( working_dir=str(self.workspace), timeout=self.exec_config.timeout, restrict_to_workspace=self.restrict_to_workspace, )) - + # Web tools self.tools.register(WebSearchTool(api_key=self.brave_api_key)) self.tools.register(WebFetchTool()) - + # Message tool message_tool = MessageTool(send_callback=self.bus.publish_outbound) self.tools.register(message_tool) - + # Spawn tool (for subagents) spawn_tool = SpawnTool(manager=self.subagents) self.tools.register(spawn_tool) - + # Cron tool (for scheduling) if self.cron_service: self.tools.register(CronTool(self.cron_service)) - + async def _connect_mcp(self) -> None: """Connect to configured MCP servers (one-time, lazy).""" if self._mcp_connected or not self._mcp_servers: @@ -270,7 +275,7 @@ class AgentLoop: )) except asyncio.TimeoutError: continue - + async def close_mcp(self) -> None: """Close MCP connections.""" if self._mcp_stack: @@ -284,7 +289,7 @@ class AgentLoop: """Stop the agent loop.""" self._running = False logger.info("Agent loop stopping") - + async def _process_message( self, msg: InboundMessage, @@ -293,25 +298,25 @@ class AgentLoop: ) -> 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. """ # System messages route back via chat_id ("channel:chat_id") if msg.channel == "system": return await self._process_system_message(msg) - + preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview) - + key = session_key or msg.session_key session = self.sessions.get_or_create(key) - + # Handle slash commands cmd = msg.content.strip().lower() if cmd == "/new": @@ -332,7 +337,7 @@ class AgentLoop: if cmd == "/help": return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content="🐈 nanobot commands:\n/new β€” Start a new conversation\n/help β€” Show available commands") - + if len(session.messages) > self.memory_window and session.key not in self._consolidating: self._consolidating.add(session.key) @@ -345,6 +350,10 @@ class AgentLoop: asyncio.create_task(_consolidate_and_unlock()) self._set_tool_context(msg.channel, msg.chat_id, msg.metadata.get("message_id")) + if message_tool := self.tools.get("message"): + if isinstance(message_tool, MessageTool): + message_tool.start_turn() + initial_messages = self.context.build_messages( history=session.get_history(max_messages=self.memory_window), current_message=msg.content, @@ -365,31 +374,44 @@ class AgentLoop: if final_content is None: final_content = "I've completed processing but have no response to give." - + preview = final_content[:120] + "..." if len(final_content) > 120 else final_content logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview) - + session.add_message("user", msg.content) session.add_message("assistant", final_content, tools_used=tools_used if tools_used else None) self.sessions.save(session) - + + suppress_final_reply = False + if message_tool := self.tools.get("message"): + if isinstance(message_tool, MessageTool): + sent_targets = set(message_tool.get_turn_sends()) + suppress_final_reply = (msg.channel, msg.chat_id) in sent_targets + + if suppress_final_reply: + logger.info( + "Skipping final auto-reply because message tool already sent to " + f"{msg.channel}:{msg.chat_id} in this turn" + ) + return None + return OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content=final_content, metadata=msg.metadata or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts) ) - + async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None: """ Process a system message (e.g., subagent announce). - + The chat_id field contains "original_channel:original_chat_id" to route the response back to the correct destination. """ logger.info("Processing system message from {}", msg.sender_id) - + # Parse origin from chat_id (format: "channel:chat_id") if ":" in msg.chat_id: parts = msg.chat_id.split(":", 1) @@ -399,7 +421,7 @@ class AgentLoop: # Fallback origin_channel = "cli" origin_chat_id = msg.chat_id - + session_key = f"{origin_channel}:{origin_chat_id}" session = self.sessions.get_or_create(session_key) self._set_tool_context(origin_channel, origin_chat_id, msg.metadata.get("message_id")) @@ -413,17 +435,17 @@ class AgentLoop: if final_content is None: final_content = "Background task completed." - + session.add_message("user", f"[System: {msg.sender_id}] {msg.content}") session.add_message("assistant", final_content) self.sessions.save(session) - + return OutboundMessage( channel=origin_channel, chat_id=origin_chat_id, content=final_content ) - + async def _consolidate_memory(self, session, archive_all: bool = False) -> None: """Consolidate old messages into MEMORY.md + HISTORY.md. @@ -533,14 +555,14 @@ Respond with ONLY valid JSON, no markdown fences.""" ) -> str: """ Process a message directly (for CLI or cron usage). - + Args: content: The message content. 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. """ @@ -551,6 +573,6 @@ Respond with ONLY valid JSON, no markdown fences.""" chat_id=chat_id, content=content ) - + response = await self._process_message(msg, session_key=session_key, on_progress=on_progress) return response.content if response else "" diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py index 10947c4..593bc44 100644 --- a/nanobot/agent/tools/message.py +++ b/nanobot/agent/tools/message.py @@ -1,6 +1,6 @@ """Message tool for sending messages to users.""" -from typing import Any, Callable, Awaitable +from typing import Any, Awaitable, Callable from nanobot.agent.tools.base import Tool from nanobot.bus.events import OutboundMessage @@ -8,37 +8,45 @@ from nanobot.bus.events import OutboundMessage class MessageTool(Tool): """Tool to send messages to users on chat channels.""" - + def __init__( - self, + self, send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None, default_channel: str = "", default_chat_id: str = "", - default_message_id: str | None = None + default_message_id: str | None = None, ): self._send_callback = send_callback self._default_channel = default_channel self._default_chat_id = default_chat_id self._default_message_id = default_message_id - + self._turn_sends: list[tuple[str, str]] = [] + def set_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None: """Set the current message context.""" self._default_channel = channel self._default_chat_id = chat_id self._default_message_id = message_id - def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None: """Set the callback for sending messages.""" self._send_callback = callback - + + def start_turn(self) -> None: + """Reset per-turn send tracking.""" + self._turn_sends.clear() + + def get_turn_sends(self) -> list[tuple[str, str]]: + """Get (channel, chat_id) targets sent in the current turn.""" + return list(self._turn_sends) + @property def name(self) -> str: return "message" - + @property def description(self) -> str: return "Send a message to the user. Use this when you want to communicate something." - + @property def parameters(self) -> dict[str, Any]: return { @@ -64,11 +72,11 @@ class MessageTool(Tool): }, "required": ["content"] } - + async def execute( - self, - content: str, - channel: str | None = None, + self, + content: str, + channel: str | None = None, chat_id: str | None = None, message_id: str | None = None, media: list[str] | None = None, @@ -77,13 +85,13 @@ class MessageTool(Tool): channel = channel or self._default_channel chat_id = chat_id or self._default_chat_id message_id = message_id or self._default_message_id - + if not channel or not chat_id: return "Error: No target channel/chat specified" - + if not self._send_callback: return "Error: Message sending not configured" - + msg = OutboundMessage( channel=channel, chat_id=chat_id, @@ -93,9 +101,10 @@ class MessageTool(Tool): "message_id": message_id, } ) - + try: await self._send_callback(msg) + self._turn_sends.append((channel, chat_id)) media_info = f" with {len(media)} attachments" if media else "" return f"Message sent to {channel}:{chat_id}{media_info}" except Exception as e: From 4eb07c44b982076f29331e2d41e1f6963cfc48db Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Fri, 20 Feb 2026 09:21:27 -0300 Subject: [PATCH 04/46] fix: preserve interim content as fallback when retry produces empty response Fixes regression from #825 where models that respond with final text directly (no tools) had their answer discarded by the retry mechanism. Closes #878 --- nanobot/agent/loop.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 3016d92..d817815 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -185,6 +185,7 @@ class AgentLoop: final_content = None tools_used: list[str] = [] text_only_retried = False + interim_content: str | None = None # Fallback if retry produces nothing while iteration < self.max_iterations: iteration += 1 @@ -231,9 +232,11 @@ class AgentLoop: else: final_content = self._strip_think(response.content) # Some models send an interim text response before tool calls. - # Give them one retry; don't forward the text to avoid duplicates. + # Give them one retry; save the content as fallback in case + # the retry produces nothing useful (e.g. model already answered). if not tools_used and not text_only_retried and final_content: text_only_retried = True + interim_content = final_content logger.debug("Interim text response (no tools used yet), retrying: {}", final_content[:80]) messages = self.context.add_assistant_message( messages, response.content, @@ -241,6 +244,9 @@ class AgentLoop: ) final_content = None continue + # Fall back to interim content if retry produced nothing + if not final_content and interim_content: + final_content = interim_content break return final_content, tools_used From 44f44b305a60ba27adf4ed8bdfb577412b6d9176 Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Fri, 20 Feb 2026 09:24:48 -0300 Subject: [PATCH 05/46] fix: move MCP connected flag after successful connection to allow retry The flag was set before the connection attempt, so if any MCP server was temporarily unavailable, the flag stayed True and MCP tools were permanently lost for the session. Closes #889 --- nanobot/agent/loop.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 3016d92..9078318 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -128,11 +128,20 @@ class AgentLoop: """Connect to configured MCP servers (one-time, lazy).""" if self._mcp_connected or not self._mcp_servers: return - self._mcp_connected = True from nanobot.agent.tools.mcp import connect_mcp_servers - self._mcp_stack = AsyncExitStack() - await self._mcp_stack.__aenter__() - await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack) + try: + self._mcp_stack = AsyncExitStack() + await self._mcp_stack.__aenter__() + await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack) + self._mcp_connected = True + except Exception as e: + logger.error("Failed to connect MCP servers (will retry next message): {}", e) + if self._mcp_stack: + try: + await self._mcp_stack.aclose() + except Exception: + pass + self._mcp_stack = None def _set_tool_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None: """Update context for all tools that need routing info.""" From 8cc54b188d81504f39ea15cfd656b4b40af0eb8a Mon Sep 17 00:00:00 2001 From: Kim <150593189+KimGLee@users.noreply.github.com> Date: Fri, 20 Feb 2026 20:25:46 +0800 Subject: [PATCH 06/46] style(logging): use loguru parameterized formatting in suppression log --- nanobot/agent/loop.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 926fad9..646b658 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -391,8 +391,9 @@ class AgentLoop: if suppress_final_reply: logger.info( - "Skipping final auto-reply because message tool already sent to " - f"{msg.channel}:{msg.chat_id} in this turn" + "Skipping final auto-reply because message tool already sent to {}:{} in this turn", + msg.channel, + msg.chat_id, ) return None From 45f33853cf952b8642c104a5ecaa263a473f4963 Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Fri, 20 Feb 2026 09:37:42 -0300 Subject: [PATCH 07/46] fix: only apply interim fallback when no tools were used Addresses Codex review: if the model sent interim text then used tools, the interim text should not be used as fallback for the final response. --- nanobot/agent/loop.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index d817815..177302e 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -245,7 +245,8 @@ class AgentLoop: final_content = None continue # Fall back to interim content if retry produced nothing - if not final_content and interim_content: + # and no tools were used (if tools ran, interim was truly interim) + if not final_content and interim_content and not tools_used: final_content = interim_content break From 37222f9c0a118c449f0ef291c380916e3ad7ae62 Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Fri, 20 Feb 2026 09:38:22 -0300 Subject: [PATCH 08/46] fix: add connecting guard to prevent concurrent MCP connection attempts Addresses Codex review: concurrent callers could both pass the _mcp_connected guard and race through _connect_mcp(). Added _mcp_connecting flag set immediately to serialize attempts. --- nanobot/agent/loop.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 9078318..d14b6ea 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -89,6 +89,7 @@ class AgentLoop: self._mcp_servers = mcp_servers or {} self._mcp_stack: AsyncExitStack | None = None self._mcp_connected = False + self._mcp_connecting = False self._consolidating: set[str] = set() # Session keys with consolidation in progress self._register_default_tools() @@ -126,8 +127,9 @@ class AgentLoop: async def _connect_mcp(self) -> None: """Connect to configured MCP servers (one-time, lazy).""" - if self._mcp_connected or not self._mcp_servers: + if self._mcp_connected or self._mcp_connecting or not self._mcp_servers: return + self._mcp_connecting = True from nanobot.agent.tools.mcp import connect_mcp_servers try: self._mcp_stack = AsyncExitStack() @@ -142,6 +144,8 @@ class AgentLoop: except Exception: pass self._mcp_stack = None + finally: + self._mcp_connecting = False def _set_tool_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None: """Update context for all tools that need routing info.""" From 4c75e1673fb546a9da3e3730a91fb6fe0165e70b Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Fri, 20 Feb 2026 09:55:22 -0300 Subject: [PATCH 09/46] fix: split Discord messages exceeding 2000-character limit Discord's API rejects messages longer than 2000 characters with HTTP 400. Previously, long agent responses were silently lost after retries exhausted. Adds _split_message() (matching Telegram's approach) to chunk content at line boundaries before sending. Only the first chunk carries the reply reference. Retry logic extracted to _send_payload() for reuse across chunks. Closes #898 --- nanobot/channels/discord.py | 74 ++++++++++++++++++++++++++----------- 1 file changed, 52 insertions(+), 22 deletions(-) diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index 8baecbf..c073a05 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -17,6 +17,27 @@ from nanobot.config.schema import DiscordConfig DISCORD_API_BASE = "https://discord.com/api/v10" MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 # 20MB +MAX_MESSAGE_LEN = 2000 # Discord message character limit + + +def _split_message(content: str, max_len: int = MAX_MESSAGE_LEN) -> list[str]: + """Split content into chunks within max_len, preferring line breaks.""" + if len(content) <= max_len: + return [content] + chunks: list[str] = [] + while content: + if len(content) <= max_len: + chunks.append(content) + break + cut = content[:max_len] + pos = cut.rfind('\n') + if pos == -1: + pos = cut.rfind(' ') + if pos == -1: + pos = max_len + chunks.append(content[:pos]) + content = content[pos:].lstrip() + return chunks class DiscordChannel(BaseChannel): @@ -79,34 +100,43 @@ class DiscordChannel(BaseChannel): return url = f"{DISCORD_API_BASE}/channels/{msg.chat_id}/messages" - payload: dict[str, Any] = {"content": msg.content} - - if msg.reply_to: - payload["message_reference"] = {"message_id": msg.reply_to} - payload["allowed_mentions"] = {"replied_user": False} - headers = {"Authorization": f"Bot {self.config.token}"} try: - for attempt in range(3): - try: - response = await self._http.post(url, headers=headers, json=payload) - if response.status_code == 429: - data = response.json() - retry_after = float(data.get("retry_after", 1.0)) - logger.warning("Discord rate limited, retrying in {}s", retry_after) - await asyncio.sleep(retry_after) - continue - response.raise_for_status() - return - except Exception as e: - if attempt == 2: - logger.error("Error sending Discord message: {}", e) - else: - await asyncio.sleep(1) + chunks = _split_message(msg.content or "") + for i, chunk in enumerate(chunks): + payload: dict[str, Any] = {"content": chunk} + + # Only set reply reference on the first chunk + if i == 0 and msg.reply_to: + payload["message_reference"] = {"message_id": msg.reply_to} + payload["allowed_mentions"] = {"replied_user": False} + + await self._send_payload(url, headers, payload) finally: await self._stop_typing(msg.chat_id) + async def _send_payload( + self, url: str, headers: dict[str, str], payload: dict[str, Any] + ) -> None: + """Send a single Discord API payload with retry on rate-limit.""" + for attempt in range(3): + try: + response = await self._http.post(url, headers=headers, json=payload) + if response.status_code == 429: + data = response.json() + retry_after = float(data.get("retry_after", 1.0)) + logger.warning("Discord rate limited, retrying in {}s", retry_after) + await asyncio.sleep(retry_after) + continue + response.raise_for_status() + return + except Exception as e: + if attempt == 2: + logger.error("Error sending Discord message: {}", e) + else: + await asyncio.sleep(1) + async def _gateway_loop(self) -> None: """Main gateway loop: identify, heartbeat, dispatch events.""" if not self._ws: From 73530d51acc6229ac3c7020a4129f06813478b83 Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Fri, 20 Feb 2026 09:57:11 -0300 Subject: [PATCH 10/46] fix: store session key in JSONL metadata to avoid lossy filename reconstruction list_sessions() previously reconstructed the session key by replacing all underscores in the filename with colons. This is lossy: a key like 'cli:user_name' became 'cli:user:name' after round-tripping. Now the actual key is persisted in the metadata line during save() and read back in list_sessions(). Legacy files without the key field fall back to replacing only the first underscore, which handles the common channel:chat_id pattern correctly. Closes #899 --- nanobot/session/manager.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 9c1e427..35f8d13 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -154,6 +154,7 @@ class SessionManager: with open(path, "w", encoding="utf-8") as f: metadata_line = { "_type": "metadata", + "key": session.key, "created_at": session.created_at.isoformat(), "updated_at": session.updated_at.isoformat(), "metadata": session.metadata, @@ -186,8 +187,11 @@ class SessionManager: if first_line: data = json.loads(first_line) if data.get("_type") == "metadata": + # Prefer the key stored in metadata; fall back to + # filename-based heuristic for legacy files. + key = data.get("key") or path.stem.replace("_", ":", 1) sessions.append({ - "key": path.stem.replace("_", ":"), + "key": key, "created_at": data.get("created_at"), "updated_at": data.get("updated_at"), "path": str(path) From f19baa8fc40f5a2a66c07d4e02847280dfbd55da Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Fri, 20 Feb 2026 10:01:38 -0300 Subject: [PATCH 11/46] fix: convert remaining f-string logger calls to loguru native format Follow-up to #864. Three f-string logger calls in base.py and dingtalk.py were missed in the original sweep. These can cause KeyError if interpolated values contain curly braces, since loguru interprets them as format placeholders. --- nanobot/channels/base.py | 5 +++-- nanobot/channels/dingtalk.py | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index 30fcd1a..3a5a785 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -105,8 +105,9 @@ class BaseChannel(ABC): """ if not self.is_allowed(sender_id): logger.warning( - f"Access denied for sender {sender_id} on channel {self.name}. " - f"Add them to allowFrom list in config to grant access." + "Access denied for sender {} on channel {}. " + "Add them to allowFrom list in config to grant access.", + sender_id, self.name, ) return diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py index b7263b3..09c7714 100644 --- a/nanobot/channels/dingtalk.py +++ b/nanobot/channels/dingtalk.py @@ -58,7 +58,8 @@ class NanobotDingTalkHandler(CallbackHandler): if not content: logger.warning( - f"Received empty or unsupported message type: {chatbot_msg.message_type}" + "Received empty or unsupported message type: {}", + chatbot_msg.message_type, ) return AckMessage.STATUS_OK, "OK" @@ -126,7 +127,8 @@ class DingTalkChannel(BaseChannel): self._http = httpx.AsyncClient() logger.info( - f"Initializing DingTalk Stream Client with Client ID: {self.config.client_id}..." + "Initializing DingTalk Stream Client with Client ID: {}...", + self.config.client_id, ) credential = Credential(self.config.client_id, self.config.client_secret) self._client = DingTalkStreamClient(credential) From 4cbd8572504320f9ef54948db9d5fbbca7130e68 Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Fri, 20 Feb 2026 10:09:04 -0300 Subject: [PATCH 12/46] fix: handle edge cases in message splitting and send failure - _split_message: return empty list for empty/None content instead of a list with one empty string (Discord rejects empty content) - _split_message: use pos <= 0 fallback to prevent empty chunks when content starts with a newline or space - _send_payload: return bool to indicate success/failure - send: abort remaining chunks when a chunk fails to send, preventing partial/corrupted message delivery --- nanobot/channels/discord.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index c073a05..5a1cf5c 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -22,6 +22,8 @@ MAX_MESSAGE_LEN = 2000 # Discord message character limit def _split_message(content: str, max_len: int = MAX_MESSAGE_LEN) -> list[str]: """Split content into chunks within max_len, preferring line breaks.""" + if not content: + return [] if len(content) <= max_len: return [content] chunks: list[str] = [] @@ -31,9 +33,9 @@ def _split_message(content: str, max_len: int = MAX_MESSAGE_LEN) -> list[str]: break cut = content[:max_len] pos = cut.rfind('\n') - if pos == -1: + if pos <= 0: pos = cut.rfind(' ') - if pos == -1: + if pos <= 0: pos = max_len chunks.append(content[:pos]) content = content[pos:].lstrip() @@ -104,6 +106,9 @@ class DiscordChannel(BaseChannel): try: chunks = _split_message(msg.content or "") + if not chunks: + return + for i, chunk in enumerate(chunks): payload: dict[str, Any] = {"content": chunk} @@ -112,14 +117,18 @@ class DiscordChannel(BaseChannel): payload["message_reference"] = {"message_id": msg.reply_to} payload["allowed_mentions"] = {"replied_user": False} - await self._send_payload(url, headers, payload) + if not await self._send_payload(url, headers, payload): + break # Abort remaining chunks on failure finally: await self._stop_typing(msg.chat_id) async def _send_payload( self, url: str, headers: dict[str, str], payload: dict[str, Any] - ) -> None: - """Send a single Discord API payload with retry on rate-limit.""" + ) -> bool: + """Send a single Discord API payload with retry on rate-limit. + + Returns True on success, False if all attempts failed. + """ for attempt in range(3): try: response = await self._http.post(url, headers=headers, json=payload) @@ -130,12 +139,13 @@ class DiscordChannel(BaseChannel): await asyncio.sleep(retry_after) continue response.raise_for_status() - return + return True except Exception as e: if attempt == 2: logger.error("Error sending Discord message: {}", e) else: await asyncio.sleep(1) + return False async def _gateway_loop(self) -> None: """Main gateway loop: identify, heartbeat, dispatch events.""" From b286457c854fd3d60228b680ec0dbc8d29286303 Mon Sep 17 00:00:00 2001 From: tercerapersona Date: Fri, 20 Feb 2026 11:34:50 -0300 Subject: [PATCH 13/46] add Openrouter prompt caching via cache_control --- nanobot/providers/registry.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 445d977..ecf092f 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -100,6 +100,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( default_api_base="https://openrouter.ai/api/v1", strip_model_prefix=False, model_overrides=(), + supports_prompt_caching=True, ), # AiHubMix: global gateway, OpenAI-compatible interface. From cc04bc4dd1806f30e2f622a2628ddb40afc84fe7 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 20 Feb 2026 15:14:45 +0000 Subject: [PATCH 14/46] fix: check gateway's supports_prompt_caching instead of always returning False --- nanobot/providers/litellm_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index edeb5c6..58c9ac2 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -111,7 +111,7 @@ class LiteLLMProvider(LLMProvider): def _supports_cache_control(self, model: str) -> bool: """Return True when the provider supports cache_control on content blocks.""" if self._gateway is not None: - return False + return self._gateway.supports_prompt_caching spec = find_by_model(model) return spec is not None and spec.supports_prompt_caching From 6bcfbd9610687875c53ce65e64538e7200913591 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 20 Feb 2026 15:19:18 +0000 Subject: [PATCH 15/46] style: remove redundant comments and use loguru native format --- nanobot/channels/slack.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index 26a3966..4fc1f41 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -86,7 +86,6 @@ class SlackChannel(BaseChannel): use_thread = thread_ts and channel_type != "im" thread_ts_param = thread_ts if use_thread else None - # Send text message if content is present if msg.content: await self._web_client.chat_postMessage( channel=msg.chat_id, @@ -94,7 +93,6 @@ class SlackChannel(BaseChannel): thread_ts=thread_ts_param, ) - # Upload media files if present for media_path in msg.media or []: try: await self._web_client.files_upload_v2( @@ -103,7 +101,7 @@ class SlackChannel(BaseChannel): thread_ts=thread_ts_param, ) except Exception as e: - logger.error(f"Failed to upload file {media_path}: {e}") + logger.error("Failed to upload file {}: {}", media_path, e) except Exception as e: logger.error("Error sending Slack message: {}", e) From b853222c8755bfb3405735b13bc2c1deae20d437 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 20 Feb 2026 15:26:12 +0000 Subject: [PATCH 16/46] style: trim _send_payload docstring --- nanobot/channels/discord.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index 5a1cf5c..1d2d7a6 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -125,10 +125,7 @@ class DiscordChannel(BaseChannel): async def _send_payload( self, url: str, headers: dict[str, str], payload: dict[str, Any] ) -> bool: - """Send a single Discord API payload with retry on rate-limit. - - Returns True on success, False if all attempts failed. - """ + """Send a single Discord API payload with retry on rate-limit. Returns True on success.""" for attempt in range(3): try: response = await self._http.post(url, headers=headers, json=payload) From d9cc144575a5186b9ae6be8ff520d981f1f12fe5 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 20 Feb 2026 15:42:24 +0000 Subject: [PATCH 17/46] style: remove redundant comment in list_sessions --- nanobot/session/manager.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 35f8d13..18e23b2 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -187,8 +187,6 @@ class SessionManager: if first_line: data = json.loads(first_line) if data.get("_type") == "metadata": - # Prefer the key stored in metadata; fall back to - # filename-based heuristic for legacy files. key = data.get("key") or path.stem.replace("_", ":", 1) sessions.append({ "key": key, From 132807a3fbbd851cfef2c21f469e32216aa18cb7 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 20 Feb 2026 15:55:30 +0000 Subject: [PATCH 18/46] refactor: simplify message tool turn tracking to a single boolean flag --- nanobot/agent/loop.py | 14 ++------------ nanobot/agent/tools/message.py | 11 ++++------- 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 646b658..51d0bc0 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -383,19 +383,9 @@ class AgentLoop: tools_used=tools_used if tools_used else None) self.sessions.save(session) - suppress_final_reply = False if message_tool := self.tools.get("message"): - if isinstance(message_tool, MessageTool): - sent_targets = set(message_tool.get_turn_sends()) - suppress_final_reply = (msg.channel, msg.chat_id) in sent_targets - - if suppress_final_reply: - logger.info( - "Skipping final auto-reply because message tool already sent to {}:{} in this turn", - msg.channel, - msg.chat_id, - ) - return None + if isinstance(message_tool, MessageTool) and message_tool._sent_in_turn: + return None return OutboundMessage( channel=msg.channel, diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py index 593bc44..40e76e3 100644 --- a/nanobot/agent/tools/message.py +++ b/nanobot/agent/tools/message.py @@ -20,24 +20,21 @@ class MessageTool(Tool): self._default_channel = default_channel self._default_chat_id = default_chat_id self._default_message_id = default_message_id - self._turn_sends: list[tuple[str, str]] = [] + self._sent_in_turn: bool = False def set_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None: """Set the current message context.""" self._default_channel = channel self._default_chat_id = chat_id self._default_message_id = message_id + def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None: """Set the callback for sending messages.""" self._send_callback = callback def start_turn(self) -> None: """Reset per-turn send tracking.""" - self._turn_sends.clear() - - def get_turn_sends(self) -> list[tuple[str, str]]: - """Get (channel, chat_id) targets sent in the current turn.""" - return list(self._turn_sends) + self._sent_in_turn = False @property def name(self) -> str: @@ -104,7 +101,7 @@ class MessageTool(Tool): try: await self._send_callback(msg) - self._turn_sends.append((channel, chat_id)) + self._sent_in_turn = True media_info = f" with {len(media)} attachments" if media else "" return f"Message sent to {channel}:{chat_id}{media_info}" except Exception as e: From 7279ff0167fbbdcd06f380b0c52e69371f95b73b Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 20 Feb 2026 16:45:21 +0000 Subject: [PATCH 19/46] refactor: route CLI interactive mode through message bus for subagent support --- nanobot/agent/loop.py | 9 ++++-- nanobot/cli/commands.py | 62 +++++++++++++++++++++++++++++++++++------ 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index e5ed6e4..9f1e265 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -277,8 +277,9 @@ class AgentLoop: ) try: response = await self._process_message(msg) - if response: - await self.bus.publish_outbound(response) + await self.bus.publish_outbound(response or OutboundMessage( + channel=msg.channel, chat_id=msg.chat_id, content="", + )) except Exception as e: logger.error("Error processing message: {}", e) await self.bus.publish_outbound(OutboundMessage( @@ -376,9 +377,11 @@ class AgentLoop: ) async def _bus_progress(content: str) -> None: + meta = dict(msg.metadata or {}) + meta["_progress"] = True await self.bus.publish_outbound(OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content=content, - metadata=msg.metadata or {}, + metadata=meta, )) final_content, tools_used = await self._run_agent_loop( diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index a135349..6155463 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -498,27 +498,58 @@ def agent( console.print(f" [dim]↳ {content}[/dim]") if message: - # Single message mode + # Single message mode β€” direct call, no bus needed async def run_once(): with _thinking_ctx(): 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() - + asyncio.run(run_once()) else: - # Interactive mode + # Interactive mode β€” route through bus like other channels + from nanobot.bus.events import InboundMessage _init_prompt_session() console.print(f"{__logo__} Interactive mode (type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit)\n") + if ":" in session_id: + cli_channel, cli_chat_id = session_id.split(":", 1) + else: + cli_channel, cli_chat_id = "cli", session_id + def _exit_on_sigint(signum, frame): _restore_terminal() console.print("\nGoodbye!") os._exit(0) signal.signal(signal.SIGINT, _exit_on_sigint) - + async def run_interactive(): + bus_task = asyncio.create_task(agent_loop.run()) + turn_done = asyncio.Event() + turn_done.set() + turn_response: list[str] = [] + + async def _consume_outbound(): + while True: + try: + msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0) + if msg.metadata.get("_progress"): + console.print(f" [dim]↳ {msg.content}[/dim]") + elif not turn_done.is_set(): + if msg.content: + turn_response.append(msg.content) + turn_done.set() + elif msg.content: + console.print() + _print_agent_response(msg.content, render_markdown=markdown) + except asyncio.TimeoutError: + continue + except asyncio.CancelledError: + break + + outbound_task = asyncio.create_task(_consume_outbound()) + try: while True: try: @@ -532,10 +563,22 @@ def agent( _restore_terminal() console.print("\nGoodbye!") break - + + turn_done.clear() + turn_response.clear() + + await bus.publish_inbound(InboundMessage( + channel=cli_channel, + sender_id="user", + chat_id=cli_chat_id, + content=user_input, + )) + with _thinking_ctx(): - response = await agent_loop.process_direct(user_input, session_id, on_progress=_cli_progress) - _print_agent_response(response, render_markdown=markdown) + await turn_done.wait() + + if turn_response: + _print_agent_response(turn_response[0], render_markdown=markdown) except KeyboardInterrupt: _restore_terminal() console.print("\nGoodbye!") @@ -545,8 +588,11 @@ def agent( console.print("\nGoodbye!") break finally: + agent_loop.stop() + outbound_task.cancel() + await asyncio.gather(bus_task, outbound_task, return_exceptions=True) await agent_loop.close_mcp() - + asyncio.run(run_interactive()) From 9a31571b6dd9e5aec13df46aebdd13d65ccb99ad Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 20 Feb 2026 16:49:40 +0000 Subject: [PATCH 20/46] fix: don't append interim assistant message before retry to avoid prefill errors --- nanobot/agent/loop.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 9f1e265..4850f9c 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -253,10 +253,6 @@ class AgentLoop: if not tools_used and not text_only_retried and final_content: text_only_retried = True logger.debug("Interim text response (no tools used yet), retrying: {}", final_content[:80]) - messages = self.context.add_assistant_message( - messages, response.content, - reasoning_content=response.reasoning_content, - ) final_content = None continue break From 33396a522aaf76d5da758666e63310e2a078894a Mon Sep 17 00:00:00 2001 From: themavik Date: Fri, 20 Feb 2026 23:52:40 -0500 Subject: [PATCH 21/46] fix(tools): provide detailed error messages in edit_file when old_text not found Uses difflib to find the best match and shows a helpful diff, making it easier to debug edit_file failures. Co-authored-by: Cursor --- nanobot/agent/tools/filesystem.py | 35 ++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index 419b088..d9ff265 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -150,7 +150,7 @@ class EditFileTool(Tool): content = file_path.read_text(encoding="utf-8") if old_text not in content: - return f"Error: old_text not found in file. Make sure it matches exactly." + return self._not_found_message(old_text, content, path) # Count occurrences count = content.count(old_text) @@ -166,6 +166,39 @@ class EditFileTool(Tool): except Exception as e: return f"Error editing file: {str(e)}" + @staticmethod + def _not_found_message(old_text: str, content: str, path: str) -> str: + """Build a helpful error when old_text is not found.""" + import difflib + + lines = content.splitlines(keepends=True) + old_lines = old_text.splitlines(keepends=True) + + best_ratio = 0.0 + best_start = 0 + window = len(old_lines) + + for i in range(max(1, len(lines) - window + 1)): + chunk = lines[i : i + window] + ratio = difflib.SequenceMatcher(None, old_lines, chunk).ratio() + if ratio > best_ratio: + best_ratio = ratio + best_start = i + + if best_ratio > 0.5: + best_chunk = lines[best_start : best_start + window] + diff = difflib.unified_diff( + old_lines, best_chunk, + fromfile="old_text (provided)", tofile=f"{path} (actual, line {best_start + 1})", + lineterm="", + ) + diff_str = "\n".join(diff) + return ( + f"Error: old_text not found in {path}.\n" + f"Best match ({best_ratio:.0%} similar) at line {best_start + 1}:\n{diff_str}" + ) + return f"Error: old_text not found in {path}. No similar text found. Verify the file content." + class ListDirTool(Tool): """Tool to list directory contents.""" From 98ef57e3704860c54b86f6e8ae0d742c646883aa Mon Sep 17 00:00:00 2001 From: coldxiangyu Date: Sat, 21 Feb 2026 12:56:57 +0800 Subject: [PATCH 22/46] feat(feishu): add multimedia download support for images, audio and files Add download functionality for multimedia messages in Feishu channel, enabling agents to process images, audio recordings, and file attachments sent through Feishu. Co-Authored-By: Claude Opus 4.6 --- nanobot/channels/feishu.py | 143 ++++++++++++++++++++++++++++++------- 1 file changed, 117 insertions(+), 26 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index a8ca1fa..a948d84 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -27,6 +27,8 @@ try: CreateMessageReactionRequest, CreateMessageReactionRequestBody, Emoji, + GetFileRequest, + GetImageRequest, P2ImMessageReceiveV1, ) FEISHU_AVAILABLE = True @@ -345,6 +347,80 @@ class FeishuChannel(BaseChannel): logger.error("Error uploading file {}: {}", file_path, e) return None + def _download_image_sync(self, image_key: str) -> tuple[bytes | None, str | None]: + """Download an image from Feishu by image_key.""" + try: + request = GetImageRequest.builder().image_key(image_key).build() + response = self._client.im.v1.image.get(request) + if response.success(): + return response.file, response.file_name + else: + logger.error("Failed to download image: code={}, msg={}", response.code, response.msg) + return None, None + except Exception as e: + logger.error("Error downloading image {}: {}", image_key, e) + return None, None + + def _download_file_sync(self, file_key: str) -> tuple[bytes | None, str | None]: + """Download a file from Feishu by file_key.""" + try: + request = GetFileRequest.builder().file_key(file_key).build() + response = self._client.im.v1.file.get(request) + if response.success(): + return response.file, response.file_name + else: + logger.error("Failed to download file: code={}, msg={}", response.code, response.msg) + return None, None + except Exception as e: + logger.error("Error downloading file {}: {}", file_key, e) + return None, None + + async def _download_and_save_media( + self, + msg_type: str, + content_json: dict + ) -> tuple[str | None, str]: + """ + Download media from Feishu and save to local disk. + + Returns: + (file_path, content_text) - file_path is None if download failed + """ + from pathlib import Path + + loop = asyncio.get_running_loop() + media_dir = Path.home() / ".nanobot" / "media" + media_dir.mkdir(parents=True, exist_ok=True) + + data, filename = None, None + + if msg_type == "image": + image_key = content_json.get("image_key") + if image_key: + data, filename = await loop.run_in_executor( + None, self._download_image_sync, image_key + ) + if not filename: + filename = f"{image_key[:16]}.jpg" + + elif msg_type in ("audio", "file"): + file_key = content_json.get("file_key") + if file_key: + data, filename = await loop.run_in_executor( + None, self._download_file_sync, file_key + ) + if not filename: + ext = ".opus" if msg_type == "audio" else "" + filename = f"{file_key[:16]}{ext}" + + if data and filename: + file_path = media_dir / filename + file_path.write_bytes(data) + logger.debug("Downloaded {} to {}", msg_type, file_path) + return str(file_path), f"[{msg_type}: {filename}]" + + return None, f"[{msg_type}: download failed]" + def _send_message_sync(self, receive_id_type: str, receive_id: str, msg_type: str, content: str) -> bool: """Send a single message (text/image/file/interactive) synchronously.""" try: @@ -425,60 +501,75 @@ class FeishuChannel(BaseChannel): event = data.event message = event.message sender = event.sender - + # Deduplication check message_id = message.message_id if message_id in self._processed_message_ids: return self._processed_message_ids[message_id] = None - - # Trim cache: keep most recent 500 when exceeds 1000 + + # Trim cache while len(self._processed_message_ids) > 1000: self._processed_message_ids.popitem(last=False) - + # Skip bot messages - sender_type = sender.sender_type - if sender_type == "bot": + if sender.sender_type == "bot": return - + sender_id = sender.sender_id.open_id if sender.sender_id else "unknown" chat_id = message.chat_id - chat_type = message.chat_type # "p2p" or "group" + chat_type = message.chat_type msg_type = message.message_type - - # Add reaction to indicate "seen" + + # Add reaction await self._add_reaction(message_id, "THUMBSUP") - - # Parse message content + + # Parse content + content_parts = [] + media_paths = [] + + try: + content_json = json.loads(message.content) if message.content else {} + except json.JSONDecodeError: + content_json = {} + if msg_type == "text": - try: - content = json.loads(message.content).get("text", "") - except json.JSONDecodeError: - content = message.content or "" + text = content_json.get("text", "") + if text: + content_parts.append(text) + elif msg_type == "post": - try: - content_json = json.loads(message.content) - content = _extract_post_text(content_json) - except (json.JSONDecodeError, TypeError): - content = message.content or "" + text = _extract_post_text(content_json) + if text: + content_parts.append(text) + + elif msg_type in ("image", "audio", "file"): + file_path, content_text = await self._download_and_save_media(msg_type, content_json) + if file_path: + media_paths.append(file_path) + content_parts.append(content_text) + else: - content = MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]") - - if not content: + content_parts.append(MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]")) + + content = "\n".join(content_parts) if content_parts else "" + + if not content and not media_paths: return - + # Forward to message bus reply_to = chat_id if chat_type == "group" else sender_id await self._handle_message( sender_id=sender_id, chat_id=reply_to, content=content, + media=media_paths, metadata={ "message_id": message_id, "chat_type": chat_type, "msg_type": msg_type, } ) - + except Exception as e: logger.error("Error processing Feishu message: {}", e) From b9c3f8a5a3520fe4815f8baf1aea2f095295b3f8 Mon Sep 17 00:00:00 2001 From: coldxiangyu Date: Sat, 21 Feb 2026 14:08:25 +0800 Subject: [PATCH 23/46] feat(feishu): add share card and interactive message parsing - Add content extraction for share cards (chat, user, calendar event) - Add recursive parsing for interactive card elements - Fix image download API to use GetMessageResourceRequest with message_id - Handle BytesIO response from message resource API Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- nanobot/channels/feishu.py | 211 +++++++++++++++++++++++++++++++++++-- 1 file changed, 201 insertions(+), 10 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index a948d84..7e1d50a 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -28,7 +28,7 @@ try: CreateMessageReactionRequestBody, Emoji, GetFileRequest, - GetImageRequest, + GetMessageResourceRequest, P2ImMessageReceiveV1, ) FEISHU_AVAILABLE = True @@ -46,6 +46,182 @@ MSG_TYPE_MAP = { } +def _extract_share_card_content(content_json: dict, msg_type: str) -> str: + """Extract content from share cards and interactive messages. + + Handles: + - share_chat: Group share card + - share_user: User share card + - interactive: Interactive card (may contain links from external shares) + - share_calendar_event: Calendar event share + - system: System messages + """ + parts = [] + + if msg_type == "share_chat": + # Group share: {"chat_id": "oc_xxx"} + chat_id = content_json.get("chat_id", "") + parts.append(f"[εˆ†δΊ«ηΎ€θŠ: {chat_id}]") + + elif msg_type == "share_user": + # User share: {"user_id": "ou_xxx"} + user_id = content_json.get("user_id", "") + parts.append(f"[εˆ†δΊ«η”¨ζˆ·: {user_id}]") + + elif msg_type == "interactive": + # Interactive card - extract text and links recursively + parts.extend(_extract_interactive_content(content_json)) + + elif msg_type == "share_calendar_event": + # Calendar event share + event_key = content_json.get("event_key", "") + parts.append(f"[εˆ†δΊ«ζ—₯程: {event_key}]") + + elif msg_type == "system": + # System message + parts.append("[系统梈息]") + + elif msg_type == "merge_forward": + # Merged forward messages + parts.append("[εˆεΉΆθ½¬ε‘ζΆˆζ―]") + + return "\n".join(parts) if parts else f"[{msg_type}]" + + +def _extract_interactive_content(content: dict) -> list[str]: + """Recursively extract text and links from interactive card content.""" + parts = [] + + if isinstance(content, str): + # Try to parse as JSON + try: + content = json.loads(content) + except (json.JSONDecodeError, TypeError): + return [content] if content.strip() else [] + + if not isinstance(content, dict): + return parts + + # Extract title + if "title" in content: + title = content["title"] + if isinstance(title, dict): + title_content = title.get("content", "") or title.get("text", "") + if title_content: + parts.append(f"ζ ‡ι’˜: {title_content}") + elif isinstance(title, str): + parts.append(f"ζ ‡ι’˜: {title}") + + # Extract from elements array + elements = content.get("elements", []) + if isinstance(elements, list): + for element in elements: + parts.extend(_extract_element_content(element)) + + # Extract from card config + card = content.get("card", {}) + if card: + parts.extend(_extract_interactive_content(card)) + + # Extract header + header = content.get("header", {}) + if header: + header_title = header.get("title", {}) + if isinstance(header_title, dict): + header_text = header_title.get("content", "") or header_title.get("text", "") + if header_text: + parts.append(f"ζ ‡ι’˜: {header_text}") + + return parts + + +def _extract_element_content(element: dict) -> list[str]: + """Extract content from a single card element.""" + parts = [] + + if not isinstance(element, dict): + return parts + + tag = element.get("tag", "") + + # Markdown element + if tag == "markdown" or tag == "lark_md": + content = element.get("content", "") + if content: + parts.append(content) + + # Div element + elif tag == "div": + text = element.get("text", {}) + if isinstance(text, dict): + text_content = text.get("content", "") or text.get("text", "") + if text_content: + parts.append(text_content) + elif isinstance(text, str): + parts.append(text) + # Check for extra fields + fields = element.get("fields", []) + for field in fields: + if isinstance(field, dict): + field_text = field.get("text", {}) + if isinstance(field_text, dict): + parts.append(field_text.get("content", "")) + + # Link/URL element + elif tag == "a": + href = element.get("href", "") + text = element.get("text", "") + if href: + parts.append(f"ι“ΎζŽ₯: {href}") + if text: + parts.append(text) + + # Button element (may contain URL) + elif tag == "button": + text = element.get("text", {}) + if isinstance(text, dict): + parts.append(text.get("content", "")) + url = element.get("url", "") or element.get("multi_url", {}).get("url", "") + if url: + parts.append(f"ι“ΎζŽ₯: {url}") + + # Image element + elif tag == "img": + alt = element.get("alt", {}) + if isinstance(alt, dict): + parts.append(alt.get("content", "[图片]")) + else: + parts.append("[图片]") + + # Note element + elif tag == "note": + note_elements = element.get("elements", []) + for ne in note_elements: + parts.extend(_extract_element_content(ne)) + + # Column set + elif tag == "column_set": + columns = element.get("columns", []) + for col in columns: + col_elements = col.get("elements", []) + for ce in col_elements: + parts.extend(_extract_element_content(ce)) + + # Plain text + elif tag == "plain_text": + content = element.get("content", "") + if content: + parts.append(content) + + # Recursively check nested elements + nested = element.get("elements", []) + if isinstance(nested, list): + for ne in nested: + parts.extend(_extract_element_content(ne)) + + return parts + + def _extract_post_text(content_json: dict) -> str: """Extract plain text from Feishu post (rich text) message content. @@ -347,13 +523,21 @@ class FeishuChannel(BaseChannel): logger.error("Error uploading file {}: {}", file_path, e) return None - def _download_image_sync(self, image_key: str) -> tuple[bytes | None, str | None]: - """Download an image from Feishu by image_key.""" + def _download_image_sync(self, message_id: str, image_key: str) -> tuple[bytes | None, str | None]: + """Download an image from Feishu message by message_id and image_key.""" try: - request = GetImageRequest.builder().image_key(image_key).build() - response = self._client.im.v1.image.get(request) + request = GetMessageResourceRequest.builder() \ + .message_id(message_id) \ + .file_key(image_key) \ + .type("image") \ + .build() + response = self._client.im.v1.message_resource.get(request) if response.success(): - return response.file, response.file_name + file_data = response.file + # GetMessageResourceRequest returns BytesIO, need to read bytes + if hasattr(file_data, 'read'): + file_data = file_data.read() + return file_data, response.file_name else: logger.error("Failed to download image: code={}, msg={}", response.code, response.msg) return None, None @@ -378,7 +562,8 @@ class FeishuChannel(BaseChannel): async def _download_and_save_media( self, msg_type: str, - content_json: dict + content_json: dict, + message_id: str | None = None ) -> tuple[str | None, str]: """ Download media from Feishu and save to local disk. @@ -396,9 +581,9 @@ class FeishuChannel(BaseChannel): if msg_type == "image": image_key = content_json.get("image_key") - if image_key: + if image_key and message_id: data, filename = await loop.run_in_executor( - None, self._download_image_sync, image_key + None, self._download_image_sync, message_id, image_key ) if not filename: filename = f"{image_key[:16]}.jpg" @@ -544,11 +729,17 @@ class FeishuChannel(BaseChannel): content_parts.append(text) elif msg_type in ("image", "audio", "file"): - file_path, content_text = await self._download_and_save_media(msg_type, content_json) + file_path, content_text = await self._download_and_save_media(msg_type, content_json, message_id) if file_path: media_paths.append(file_path) content_parts.append(content_text) + elif msg_type in ("share_chat", "share_user", "interactive", "share_calendar_event", "system", "merge_forward"): + # Handle share cards and interactive messages + text = _extract_share_card_content(content_json, msg_type) + if text: + content_parts.append(text) + else: content_parts.append(MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]")) From 8125d9b6bcf39a2d92f13833aa53be45ed3a9330 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 21 Feb 2026 06:30:26 +0000 Subject: [PATCH 24/46] fix(feishu): fix double recursion, English placeholders, top-level Path import --- nanobot/channels/feishu.py | 130 ++++++++++++------------------------- 1 file changed, 43 insertions(+), 87 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 7e1d50a..815d853 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -6,6 +6,7 @@ import os import re import threading from collections import OrderedDict +from pathlib import Path from typing import Any from loguru import logger @@ -47,44 +48,22 @@ MSG_TYPE_MAP = { def _extract_share_card_content(content_json: dict, msg_type: str) -> str: - """Extract content from share cards and interactive messages. - - Handles: - - share_chat: Group share card - - share_user: User share card - - interactive: Interactive card (may contain links from external shares) - - share_calendar_event: Calendar event share - - system: System messages - """ + """Extract text representation from share cards and interactive messages.""" parts = [] - + if msg_type == "share_chat": - # Group share: {"chat_id": "oc_xxx"} - chat_id = content_json.get("chat_id", "") - parts.append(f"[εˆ†δΊ«ηΎ€θŠ: {chat_id}]") - + parts.append(f"[shared chat: {content_json.get('chat_id', '')}]") elif msg_type == "share_user": - # User share: {"user_id": "ou_xxx"} - user_id = content_json.get("user_id", "") - parts.append(f"[εˆ†δΊ«η”¨ζˆ·: {user_id}]") - + parts.append(f"[shared user: {content_json.get('user_id', '')}]") elif msg_type == "interactive": - # Interactive card - extract text and links recursively parts.extend(_extract_interactive_content(content_json)) - elif msg_type == "share_calendar_event": - # Calendar event share - event_key = content_json.get("event_key", "") - parts.append(f"[εˆ†δΊ«ζ—₯程: {event_key}]") - + parts.append(f"[shared calendar event: {content_json.get('event_key', '')}]") elif msg_type == "system": - # System message - parts.append("[系统梈息]") - + parts.append("[system message]") elif msg_type == "merge_forward": - # Merged forward messages - parts.append("[εˆεΉΆθ½¬ε‘ζΆˆζ―]") - + parts.append("[merged forward messages]") + return "\n".join(parts) if parts else f"[{msg_type}]" @@ -93,44 +72,37 @@ def _extract_interactive_content(content: dict) -> list[str]: parts = [] if isinstance(content, str): - # Try to parse as JSON try: content = json.loads(content) except (json.JSONDecodeError, TypeError): return [content] if content.strip() else [] - + if not isinstance(content, dict): return parts - - # Extract title + if "title" in content: title = content["title"] if isinstance(title, dict): title_content = title.get("content", "") or title.get("text", "") if title_content: - parts.append(f"ζ ‡ι’˜: {title_content}") + parts.append(f"title: {title_content}") elif isinstance(title, str): - parts.append(f"ζ ‡ι’˜: {title}") - - # Extract from elements array - elements = content.get("elements", []) - if isinstance(elements, list): - for element in elements: - parts.extend(_extract_element_content(element)) - - # Extract from card config + parts.append(f"title: {title}") + + for element in content.get("elements", []) if isinstance(content.get("elements"), list) else []: + parts.extend(_extract_element_content(element)) + card = content.get("card", {}) if card: parts.extend(_extract_interactive_content(card)) - - # Extract header + header = content.get("header", {}) if header: header_title = header.get("title", {}) if isinstance(header_title, dict): header_text = header_title.get("content", "") or header_title.get("text", "") if header_text: - parts.append(f"ζ ‡ι’˜: {header_text}") + parts.append(f"title: {header_text}") return parts @@ -144,13 +116,11 @@ def _extract_element_content(element: dict) -> list[str]: tag = element.get("tag", "") - # Markdown element - if tag == "markdown" or tag == "lark_md": + if tag in ("markdown", "lark_md"): content = element.get("content", "") if content: parts.append(content) - - # Div element + elif tag == "div": text = element.get("text", {}) if isinstance(text, dict): @@ -159,64 +129,52 @@ def _extract_element_content(element: dict) -> list[str]: parts.append(text_content) elif isinstance(text, str): parts.append(text) - # Check for extra fields - fields = element.get("fields", []) - for field in fields: + for field in element.get("fields", []): if isinstance(field, dict): field_text = field.get("text", {}) if isinstance(field_text, dict): - parts.append(field_text.get("content", "")) - - # Link/URL element + c = field_text.get("content", "") + if c: + parts.append(c) + elif tag == "a": href = element.get("href", "") text = element.get("text", "") if href: - parts.append(f"ι“ΎζŽ₯: {href}") + parts.append(f"link: {href}") if text: parts.append(text) - - # Button element (may contain URL) + elif tag == "button": text = element.get("text", {}) if isinstance(text, dict): - parts.append(text.get("content", "")) + c = text.get("content", "") + if c: + parts.append(c) url = element.get("url", "") or element.get("multi_url", {}).get("url", "") if url: - parts.append(f"ι“ΎζŽ₯: {url}") - - # Image element + parts.append(f"link: {url}") + elif tag == "img": alt = element.get("alt", {}) - if isinstance(alt, dict): - parts.append(alt.get("content", "[图片]")) - else: - parts.append("[图片]") - - # Note element + parts.append(alt.get("content", "[image]") if isinstance(alt, dict) else "[image]") + elif tag == "note": - note_elements = element.get("elements", []) - for ne in note_elements: + for ne in element.get("elements", []): parts.extend(_extract_element_content(ne)) - - # Column set + elif tag == "column_set": - columns = element.get("columns", []) - for col in columns: - col_elements = col.get("elements", []) - for ce in col_elements: + for col in element.get("columns", []): + for ce in col.get("elements", []): parts.extend(_extract_element_content(ce)) - - # Plain text + elif tag == "plain_text": content = element.get("content", "") if content: parts.append(content) - - # Recursively check nested elements - nested = element.get("elements", []) - if isinstance(nested, list): - for ne in nested: + + else: + for ne in element.get("elements", []): parts.extend(_extract_element_content(ne)) return parts @@ -571,8 +529,6 @@ class FeishuChannel(BaseChannel): Returns: (file_path, content_text) - file_path is None if download failed """ - from pathlib import Path - loop = asyncio.get_running_loop() media_dir = Path.home() / ".nanobot" / "media" media_dir.mkdir(parents=True, exist_ok=True) From e0edb904bd337cf5a3aaf675f39627d022043377 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 21 Feb 2026 06:35:10 +0000 Subject: [PATCH 25/46] style(filesystem): move difflib import to top level --- nanobot/agent/tools/filesystem.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index d9ff265..e6c407e 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -1,5 +1,6 @@ """File system tools: read, write, edit.""" +import difflib from pathlib import Path from typing import Any @@ -169,8 +170,6 @@ class EditFileTool(Tool): @staticmethod def _not_found_message(old_text: str, content: str, path: str) -> str: """Build a helpful error when old_text is not found.""" - import difflib - lines = content.splitlines(keepends=True) old_lines = old_text.splitlines(keepends=True) From 4f5cb7d1e421a593cc54d9d1d0aab34870c12a35 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 21 Feb 2026 06:39:04 +0000 Subject: [PATCH 26/46] style(filesystem): simplify best-match loop --- nanobot/agent/tools/filesystem.py | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index e6c407e..9c169e4 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -172,30 +172,21 @@ class EditFileTool(Tool): """Build a helpful error when old_text is not found.""" lines = content.splitlines(keepends=True) old_lines = old_text.splitlines(keepends=True) - - best_ratio = 0.0 - best_start = 0 window = len(old_lines) + best_ratio, best_start = 0.0, 0 for i in range(max(1, len(lines) - window + 1)): - chunk = lines[i : i + window] - ratio = difflib.SequenceMatcher(None, old_lines, chunk).ratio() + ratio = difflib.SequenceMatcher(None, old_lines, lines[i : i + window]).ratio() if ratio > best_ratio: - best_ratio = ratio - best_start = i + best_ratio, best_start = ratio, i if best_ratio > 0.5: - best_chunk = lines[best_start : best_start + window] - diff = difflib.unified_diff( - old_lines, best_chunk, + diff = "\n".join(difflib.unified_diff( + old_lines, lines[best_start : best_start + window], fromfile="old_text (provided)", tofile=f"{path} (actual, line {best_start + 1})", lineterm="", - ) - diff_str = "\n".join(diff) - return ( - f"Error: old_text not found in {path}.\n" - f"Best match ({best_ratio:.0%} similar) at line {best_start + 1}:\n{diff_str}" - ) + )) + return f"Error: old_text not found in {path}.\nBest match ({best_ratio:.0%} similar) at line {best_start + 1}:\n{diff}" return f"Error: old_text not found in {path}. No similar text found. Verify the file content." From c4bee640b838045d6df079c734573523f23de48d Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Sat, 21 Feb 2026 07:51:28 +0100 Subject: [PATCH 27/46] fix(agent): skip empty fallback outbound for non-cli channels --- nanobot/agent/loop.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 4850f9c..336baec 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -273,9 +273,15 @@ class AgentLoop: ) try: response = await self._process_message(msg) - await self.bus.publish_outbound(response or OutboundMessage( - channel=msg.channel, chat_id=msg.chat_id, content="", - )) + if response is not None: + await self.bus.publish_outbound(response) + elif msg.channel == "cli": + await self.bus.publish_outbound(OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content="", + metadata=msg.metadata or {}, + )) except Exception as e: logger.error("Error processing message: {}", e) await self.bus.publish_outbound(OutboundMessage( From aeb07d3450fafbcadb4ed649355ce2d3c4759225 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 21 Feb 2026 07:32:58 +0000 Subject: [PATCH 28/46] refactor(loop): remove interim text retry, use system prompt constraint instead --- nanobot/agent/context.py | 1 + nanobot/agent/loop.py | 15 --------------- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 67aad0c..9ef333c 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -106,6 +106,7 @@ Only use the 'message' tool when you need to send a message to a specific chat c For normal conversation, just respond with text - do not call the message 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). +If you need to use tools, call them directly β€” never send a preliminary message like "Let me check" without actually calling a tool. 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 2348df7..b9108e7 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -202,8 +202,6 @@ class AgentLoop: iteration = 0 final_content = None tools_used: list[str] = [] - text_only_retried = False - interim_content: str | None = None # Fallback if retry produces nothing while iteration < self.max_iterations: iteration += 1 @@ -249,19 +247,6 @@ class AgentLoop: ) else: final_content = self._strip_think(response.content) - # Some models send an interim text response before tool calls. - # Give them one retry; save the content as fallback in case - # the retry produces nothing useful (e.g. model already answered). - if not tools_used and not text_only_retried and final_content: - text_only_retried = True - interim_content = final_content - logger.debug("Interim text response (no tools used yet), retrying: {}", final_content[:80]) - final_content = None - continue - # Fall back to interim content if retry produced nothing - # and no tools were used (if tools ran, interim was truly interim) - if not final_content and interim_content and not tools_used: - final_content = interim_content break return final_content, tools_used From ab026c513162580443e1c386f13539b088aab770 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 21 Feb 2026 08:14:46 +0000 Subject: [PATCH 29/46] refactor: extract memory consolidation to MemoryStore, slim down AgentLoop --- README.md | 2 +- nanobot/agent/loop.py | 257 ++++++---------------------------------- nanobot/agent/memory.py | 108 +++++++++++++++++ 3 files changed, 147 insertions(+), 220 deletions(-) diff --git a/README.md b/README.md index 68ad5a9..c5fd908 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,827 lines** (run `bash core_agent_lines.sh` to verify anytime) +πŸ“ Real-time line count: **3,806 lines** (run `bash core_agent_lines.sh` to verify anytime) ## πŸ“’ News diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 325c1ac..50f6ec2 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -99,33 +99,18 @@ class AgentLoop: def _register_default_tools(self) -> None: """Register the default set of tools.""" - # File tools (workspace for relative paths, restrict if configured) allowed_dir = self.workspace if self.restrict_to_workspace else None - self.tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) - self.tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) - self.tools.register(EditFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) - self.tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir)) - - # Shell tool + for cls in (ReadFileTool, WriteFileTool, EditFileTool, ListDirTool): + self.tools.register(cls(workspace=self.workspace, allowed_dir=allowed_dir)) self.tools.register(ExecTool( working_dir=str(self.workspace), timeout=self.exec_config.timeout, restrict_to_workspace=self.restrict_to_workspace, )) - - # Web tools self.tools.register(WebSearchTool(api_key=self.brave_api_key)) self.tools.register(WebFetchTool()) - - # Message tool - message_tool = MessageTool(send_callback=self.bus.publish_outbound) - self.tools.register(message_tool) - - # Spawn tool (for subagents) - spawn_tool = SpawnTool(manager=self.subagents) - self.tools.register(spawn_tool) - - # Cron tool (for scheduling) + self.tools.register(MessageTool(send_callback=self.bus.publish_outbound)) + self.tools.register(SpawnTool(manager=self.subagents)) if self.cron_service: self.tools.register(CronTool(self.cron_service)) @@ -187,16 +172,7 @@ class AgentLoop: 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). - """ + """Run the agent iteration loop. Returns (final_content, tools_used).""" messages = initial_messages iteration = 0 final_content = None @@ -297,20 +273,25 @@ class AgentLoop: 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. - """ - # System messages route back via chat_id ("channel:chat_id") + """Process a single inbound message and return the response.""" + # System messages: parse origin from chat_id ("channel:chat_id") if msg.channel == "system": - return await self._process_system_message(msg) + channel, chat_id = (msg.chat_id.split(":", 1) if ":" in msg.chat_id + else ("cli", msg.chat_id)) + logger.info("Processing system message from {}", msg.sender_id) + key = f"{channel}:{chat_id}" + session = self.sessions.get_or_create(key) + self._set_tool_context(channel, chat_id, msg.metadata.get("message_id")) + messages = self.context.build_messages( + history=session.get_history(max_messages=self.memory_window), + current_message=msg.content, channel=channel, chat_id=chat_id, + ) + final_content, _ = await self._run_agent_loop(messages) + session.add_message("user", f"[System: {msg.sender_id}] {msg.content}") + session.add_message("assistant", final_content or "Background task completed.") + self.sessions.save(session) + return OutboundMessage(channel=channel, chat_id=chat_id, + content=final_content or "Background task completed.") preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview) @@ -318,19 +299,18 @@ class AgentLoop: key = session_key or msg.session_key session = self.sessions.get_or_create(key) - # Handle slash commands + # Slash commands cmd = msg.content.strip().lower() if cmd == "/new": - # Capture messages before clearing (avoid race condition with background task) messages_to_archive = session.messages.copy() session.clear() self.sessions.save(session) self.sessions.invalidate(session.key) async def _consolidate_and_cleanup(): - temp_session = Session(key=session.key) - temp_session.messages = messages_to_archive - await self._consolidate_memory(temp_session, archive_all=True) + temp = Session(key=session.key) + temp.messages = messages_to_archive + await self._consolidate_memory(temp, archive_all=True) asyncio.create_task(_consolidate_and_cleanup()) return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, @@ -359,16 +339,14 @@ class AgentLoop: history=session.get_history(max_messages=self.memory_window), current_message=msg.content, media=msg.media if msg.media else None, - channel=msg.channel, - chat_id=msg.chat_id, + channel=msg.channel, chat_id=msg.chat_id, ) async def _bus_progress(content: str) -> None: meta = dict(msg.metadata or {}) meta["_progress"] = True await self.bus.publish_outbound(OutboundMessage( - channel=msg.channel, chat_id=msg.chat_id, content=content, - metadata=meta, + channel=msg.channel, chat_id=msg.chat_id, content=content, metadata=meta, )) final_content, tools_used = await self._run_agent_loop( @@ -391,157 +369,16 @@ class AgentLoop: return None return OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content=final_content, - metadata=msg.metadata or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts) - ) - - async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None: - """ - Process a system message (e.g., subagent announce). - - The chat_id field contains "original_channel:original_chat_id" to route - the response back to the correct destination. - """ - logger.info("Processing system message from {}", msg.sender_id) - - # Parse origin from chat_id (format: "channel:chat_id") - if ":" in msg.chat_id: - parts = msg.chat_id.split(":", 1) - origin_channel = parts[0] - origin_chat_id = parts[1] - else: - # Fallback - origin_channel = "cli" - origin_chat_id = msg.chat_id - - session_key = f"{origin_channel}:{origin_chat_id}" - session = self.sessions.get_or_create(session_key) - self._set_tool_context(origin_channel, origin_chat_id, msg.metadata.get("message_id")) - initial_messages = self.context.build_messages( - history=session.get_history(max_messages=self.memory_window), - current_message=msg.content, - channel=origin_channel, - chat_id=origin_chat_id, - ) - final_content, _ = await self._run_agent_loop(initial_messages) - - if final_content is None: - final_content = "Background task completed." - - session.add_message("user", f"[System: {msg.sender_id}] {msg.content}") - session.add_message("assistant", final_content) - self.sessions.save(session) - - return OutboundMessage( - channel=origin_channel, - chat_id=origin_chat_id, - content=final_content + channel=msg.channel, chat_id=msg.chat_id, content=final_content, + metadata=msg.metadata or {}, ) async def _consolidate_memory(self, session, archive_all: bool = False) -> None: - """Consolidate old messages into MEMORY.md + HISTORY.md. - - Args: - archive_all: If True, clear all messages and reset session (for /new command). - If False, only write to files without modifying session. - """ - memory = MemoryStore(self.workspace) - - if archive_all: - old_messages = session.messages - keep_count = 0 - logger.info("Memory consolidation (archive_all): {} total messages archived", len(session.messages)) - else: - keep_count = self.memory_window // 2 - if len(session.messages) <= keep_count: - logger.debug("Session {}: No consolidation needed (messages={}, keep={})", session.key, len(session.messages), keep_count) - return - - messages_to_process = len(session.messages) - session.last_consolidated - if messages_to_process <= 0: - logger.debug("Session {}: No new messages to consolidate (last_consolidated={}, total={})", session.key, session.last_consolidated, len(session.messages)) - return - - old_messages = session.messages[session.last_consolidated:-keep_count] - if not old_messages: - return - logger.info("Memory consolidation started: {} total, {} new to consolidate, {} keep", len(session.messages), len(old_messages), keep_count) - - lines = [] - for m in old_messages: - if not m.get("content"): - continue - tools = f" [tools: {', '.join(m['tools_used'])}]" if m.get("tools_used") else "" - lines.append(f"[{m.get('timestamp', '?')[:16]}] {m['role'].upper()}{tools}: {m['content']}") - conversation = "\n".join(lines) - current_memory = memory.read_long_term() - - prompt = f"""Process this conversation and call the save_memory tool with your consolidation. - -## Current Long-term Memory -{current_memory or "(empty)"} - -## Conversation to Process -{conversation}""" - - save_memory_tool = [ - { - "type": "function", - "function": { - "name": "save_memory", - "description": "Save the memory consolidation result to persistent storage.", - "parameters": { - "type": "object", - "properties": { - "history_entry": { - "type": "string", - "description": "A paragraph (2-5 sentences) summarizing key events/decisions/topics. Start with a timestamp like [YYYY-MM-DD HH:MM]. Include enough detail to be useful when found by grep search later.", - }, - "memory_update": { - "type": "string", - "description": "The full updated long-term memory content as a markdown string. Include all existing facts plus any new facts: user location, preferences, personal info, habits, project context, technical decisions, tools/services used. If nothing new, return the existing content unchanged.", - }, - }, - "required": ["history_entry", "memory_update"], - }, - }, - } - ] - - try: - response = await self.provider.chat( - messages=[ - {"role": "system", "content": "You are a memory consolidation agent. Call the save_memory tool with your consolidation of the conversation."}, - {"role": "user", "content": prompt}, - ], - tools=save_memory_tool, - model=self.model, - ) - - if not response.has_tool_calls: - logger.warning("Memory consolidation: LLM did not call save_memory tool, skipping") - return - - args = response.tool_calls[0].arguments - if entry := args.get("history_entry"): - if not isinstance(entry, str): - entry = json.dumps(entry, ensure_ascii=False) - memory.append_history(entry) - if update := args.get("memory_update"): - if not isinstance(update, str): - update = json.dumps(update, ensure_ascii=False) - if update != current_memory: - memory.write_long_term(update) - - if archive_all: - session.last_consolidated = 0 - else: - session.last_consolidated = len(session.messages) - keep_count - logger.info("Memory consolidation done: {} messages, last_consolidated={}", len(session.messages), session.last_consolidated) - except Exception as e: - logger.error("Memory consolidation failed: {}", e) + """Delegate to MemoryStore.consolidate().""" + await MemoryStore(self.workspace).consolidate( + session, self.provider, self.model, + archive_all=archive_all, memory_window=self.memory_window, + ) async def process_direct( self, @@ -551,26 +388,8 @@ class AgentLoop: chat_id: str = "direct", on_progress: Callable[[str], Awaitable[None]] | None = None, ) -> str: - """ - Process a message directly (for CLI or cron usage). - - Args: - content: The message content. - 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. - """ + """Process a message directly (for CLI or cron usage).""" await self._connect_mcp() - msg = InboundMessage( - channel=channel, - sender_id="user", - chat_id=chat_id, - content=content - ) - + msg = InboundMessage(channel=channel, sender_id="user", chat_id=chat_id, content=content) response = await self._process_message(msg, session_key=session_key, on_progress=on_progress) return response.content if response else "" diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index 29477c4..51abd8f 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -1,9 +1,46 @@ """Memory system for persistent agent memory.""" +from __future__ import annotations + +import json from pathlib import Path +from typing import TYPE_CHECKING + +from loguru import logger from nanobot.utils.helpers import ensure_dir +if TYPE_CHECKING: + from nanobot.providers.base import LLMProvider + from nanobot.session.manager import Session + + +_SAVE_MEMORY_TOOL = [ + { + "type": "function", + "function": { + "name": "save_memory", + "description": "Save the memory consolidation result to persistent storage.", + "parameters": { + "type": "object", + "properties": { + "history_entry": { + "type": "string", + "description": "A paragraph (2-5 sentences) summarizing key events/decisions/topics. " + "Start with [YYYY-MM-DD HH:MM]. Include detail useful for grep search.", + }, + "memory_update": { + "type": "string", + "description": "Full updated long-term memory as markdown. Include all existing " + "facts plus new ones. Return unchanged if nothing new.", + }, + }, + "required": ["history_entry", "memory_update"], + }, + }, + } +] + class MemoryStore: """Two-layer memory: MEMORY.md (long-term facts) + HISTORY.md (grep-searchable log).""" @@ -28,3 +65,74 @@ class MemoryStore: def get_memory_context(self) -> str: long_term = self.read_long_term() return f"## Long-term Memory\n{long_term}" if long_term else "" + + async def consolidate( + self, + session: Session, + provider: LLMProvider, + model: str, + *, + archive_all: bool = False, + memory_window: int = 50, + ) -> None: + """Consolidate old messages into MEMORY.md + HISTORY.md via LLM tool call.""" + if archive_all: + old_messages = session.messages + keep_count = 0 + logger.info("Memory consolidation (archive_all): {} messages", len(session.messages)) + else: + keep_count = memory_window // 2 + if len(session.messages) <= keep_count: + return + if len(session.messages) - session.last_consolidated <= 0: + return + old_messages = session.messages[session.last_consolidated:-keep_count] + if not old_messages: + return + logger.info("Memory consolidation: {} to consolidate, {} keep", len(old_messages), keep_count) + + lines = [] + for m in old_messages: + if not m.get("content"): + continue + tools = f" [tools: {', '.join(m['tools_used'])}]" if m.get("tools_used") else "" + lines.append(f"[{m.get('timestamp', '?')[:16]}] {m['role'].upper()}{tools}: {m['content']}") + + current_memory = self.read_long_term() + prompt = f"""Process this conversation and call the save_memory tool with your consolidation. + +## Current Long-term Memory +{current_memory or "(empty)"} + +## Conversation to Process +{chr(10).join(lines)}""" + + try: + response = await provider.chat( + messages=[ + {"role": "system", "content": "You are a memory consolidation agent. Call the save_memory tool with your consolidation of the conversation."}, + {"role": "user", "content": prompt}, + ], + tools=_SAVE_MEMORY_TOOL, + model=model, + ) + + if not response.has_tool_calls: + logger.warning("Memory consolidation: LLM did not call save_memory, skipping") + return + + args = response.tool_calls[0].arguments + if entry := args.get("history_entry"): + if not isinstance(entry, str): + entry = json.dumps(entry, ensure_ascii=False) + self.append_history(entry) + if update := args.get("memory_update"): + if not isinstance(update, str): + update = json.dumps(update, ensure_ascii=False) + if update != current_memory: + self.write_long_term(update) + + session.last_consolidated = 0 if archive_all else len(session.messages) - keep_count + logger.info("Memory consolidation done: {} messages, last_consolidated={}", len(session.messages), session.last_consolidated) + except Exception as e: + logger.error("Memory consolidation failed: {}", e) From 0b30f514b4bee99660dc57f27a8326e749df6979 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 21 Feb 2026 08:27:49 +0000 Subject: [PATCH 30/46] style(loop): compact empty outbound message construction --- nanobot/agent/loop.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 5f0962a..7b9317c 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -244,10 +244,7 @@ class AgentLoop: await self.bus.publish_outbound(response) elif msg.channel == "cli": await self.bus.publish_outbound(OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content="", - metadata=msg.metadata or {}, + channel=msg.channel, chat_id=msg.chat_id, content="", metadata=msg.metadata or {}, )) except Exception as e: logger.error("Error processing message: {}", e) From ec4bdb651fa13f9ced4fe9955b2c69c7bae7e39d Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 21 Feb 2026 08:33:02 +0000 Subject: [PATCH 31/46] docs: update nanobot news --- README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c5fd908..bf627a0 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ ## πŸ“’ News +- **2026-02-20** 🐦 Feishu now receives images, audio, and files from users. More reliable memory under the hood. +- **2026-02-19** ✨ Slack now sends files, Discord splits long messages, and subagents work in CLI mode. +- **2026-02-18** ⚑️ nanobot now supports VolcEngine, MCP custom auth headers, and Anthropic prompt caching. - **2026-02-17** πŸŽ‰ Released **v0.1.4** β€” MCP support, progress streaming, new providers, and multiple channel improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4) for details. - **2026-02-16** 🦞 nanobot now integrates a [ClawHub](https://clawhub.ai) skill β€” search and install public agent skills. - **2026-02-15** πŸ”‘ nanobot now supports OpenAI Codex provider with OAuth login support. @@ -27,20 +30,18 @@ - **2026-02-13** πŸŽ‰ Released **v0.1.3.post7** β€” includes security hardening and multiple improvements. **Please upgrade to the latest version to address security issues**. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details. - **2026-02-12** 🧠 Redesigned memory system β€” Less code, more reliable. Join the [discussion](https://github.com/HKUDS/nanobot/discussions/566) about it! - **2026-02-11** ✨ Enhanced CLI experience and added MiniMax support! -- **2026-02-10** πŸŽ‰ Released **v0.1.3.post6** with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431). -- **2026-02-09** πŸ’¬ Added Slack, Email, and QQ support β€” nanobot now supports multiple chat platforms! -- **2026-02-08** πŸ”§ Refactored Providersβ€”adding a new LLM provider now takes just 2 simple steps! Check [here](#providers).
Earlier news - +- **2026-02-10** πŸŽ‰ Released **v0.1.3.post6** with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431). +- **2026-02-09** πŸ’¬ Added Slack, Email, and QQ support β€” nanobot now supports multiple chat platforms! +- **2026-02-08** πŸ”§ Refactored Providersβ€”adding a new LLM provider now takes just 2 simple steps! Check [here](#providers). - **2026-02-07** πŸš€ Released **v0.1.3.post5** with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details. - **2026-02-06** ✨ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening! - **2026-02-05** ✨ Added Feishu channel, DeepSeek provider, and enhanced scheduled tasks support! - **2026-02-04** πŸš€ Released **v0.1.3.post4** with multi-provider & Docker support! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details. - **2026-02-03** ⚑ Integrated vLLM for local LLM support and improved natural language task scheduling! - **2026-02-02** πŸŽ‰ nanobot officially launched! Welcome to try 🐈 nanobot! -
## Key Features of nanobot: From 9c61e1389c0ba6d33fb357c5937a5552fd8711ac Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 21 Feb 2026 08:33:31 +0000 Subject: [PATCH 32/46] docs: update nanobot news --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bf627a0..8e1202c 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ ## πŸ“’ News -- **2026-02-20** 🐦 Feishu now receives images, audio, and files from users. More reliable memory under the hood. +- **2026-02-20** 🐦 Feishu now receives multimodal files from users. More reliable memory under the hood. - **2026-02-19** ✨ Slack now sends files, Discord splits long messages, and subagents work in CLI mode. - **2026-02-18** ⚑️ nanobot now supports VolcEngine, MCP custom auth headers, and Anthropic prompt caching. - **2026-02-17** πŸŽ‰ Released **v0.1.4** β€” MCP support, progress streaming, new providers, and multiple channel improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4) for details. From b3acd19c7ba2e012916d09ae7de52b328cbb5108 Mon Sep 17 00:00:00 2001 From: vincentchen Date: Sat, 21 Feb 2026 20:28:42 +0800 Subject: [PATCH 33/46] Remove redundant tools description (because tools information is passed in with each self.provider.chat() call) --- nanobot/agent/context.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 9ef333c..5794918 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -82,12 +82,7 @@ Skills with available="false" need dependencies installed first - you can try in return f"""# nanobot 🐈 -You are nanobot, a helpful AI assistant. You have access to tools that allow you to: -- Read, write, and edit files -- Execute shell commands -- Search the web and fetch web pages -- Send messages to users on chat channels -- Spawn subagents for complex background tasks +You are nanobot, a helpful AI assistant. ## Current Time {now} ({tz}) From af71ccf0514721777aaae500f05016d844fe5fc6 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 21 Feb 2026 13:05:14 +0000 Subject: [PATCH 34/46] release: v0.1.4.post1 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 64a884d..c337d02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nanobot-ai" -version = "0.1.4" +version = "0.1.4.post1" description = "A lightweight personal AI assistant framework" requires-python = ">=3.11" license = {text = "MIT"} From 88ca2e05307b66dfebe469d884c17217ea3b17d6 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 21 Feb 2026 13:20:55 +0000 Subject: [PATCH 35/46] docs: update v.0.1.4.post1 release news --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 8e1202c..1002872 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ ## πŸ“’ News +- **2026-02-21** πŸŽ‰ Released **v0.1.4.post1** β€” new providers, media support across channels, and major stability improvements. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post1) for details. - **2026-02-20** 🐦 Feishu now receives multimodal files from users. More reliable memory under the hood. - **2026-02-19** ✨ Slack now sends files, Discord splits long messages, and subagents work in CLI mode. - **2026-02-18** ⚑️ nanobot now supports VolcEngine, MCP custom auth headers, and Anthropic prompt caching. From 01c835aac2bfc676d6213261c6ad6cb7301bb83e Mon Sep 17 00:00:00 2001 From: nanobot-bot Date: Sat, 21 Feb 2026 23:07:18 +0800 Subject: [PATCH 36/46] fix(context): Fix 'Missing `reasoning_content` field' error for deepseek provider. --- nanobot/agent/context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 876d43d..b3de8da 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -235,7 +235,7 @@ To recall past events, grep {workspace_path}/memory/HISTORY.md""" msg["tool_calls"] = tool_calls # Include reasoning content when provided (required by some thinking models) - if reasoning_content: + if reasoning_content is not None: msg["reasoning_content"] = reasoning_content messages.append(msg) From edc671a8a30fd05c46a15b0da7292fc9c4c4b5be Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 21 Feb 2026 16:39:26 +0000 Subject: [PATCH 37/46] docs: update format of news section --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 1002872..cb751ba 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@
Earlier news + - **2026-02-10** πŸŽ‰ Released **v0.1.3.post6** with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431). - **2026-02-09** πŸ’¬ Added Slack, Email, and QQ support β€” nanobot now supports multiple chat platforms! - **2026-02-08** πŸ”§ Refactored Providersβ€”adding a new LLM provider now takes just 2 simple steps! Check [here](#providers). @@ -43,6 +44,7 @@ - **2026-02-04** πŸš€ Released **v0.1.3.post4** with multi-provider & Docker support! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details. - **2026-02-03** ⚑ Integrated vLLM for local LLM support and improved natural language task scheduling! - **2026-02-02** πŸŽ‰ nanobot officially launched! Welcome to try 🐈 nanobot! +
## Key Features of nanobot: From deae84482d786f5cb99ed526bf84edeba17caba8 Mon Sep 17 00:00:00 2001 From: init-new-world Date: Sun, 22 Feb 2026 00:42:41 +0800 Subject: [PATCH 38/46] fix: change VolcEngine litellm prefix from openai to volcengine --- nanobot/providers/registry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index ecf092f..2766929 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -147,7 +147,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( keywords=("volcengine", "volces", "ark"), env_key="OPENAI_API_KEY", display_name="VolcEngine", - litellm_prefix="openai", + litellm_prefix="volcengine", skip_prefixes=(), env_extras=(), is_gateway=True, From 5c9cb3a208a0a9b3e543c510753269073579adaa Mon Sep 17 00:00:00 2001 From: andienguyen-ecoligo Date: Sat, 21 Feb 2026 12:34:14 -0500 Subject: [PATCH 39/46] fix(security): prevent path traversal bypass via startswith check `startswith` string comparison allows bypassing directory restrictions. For example, `/home/user/workspace_evil` passes the check against `/home/user/workspace` because the string starts with the allowed path. Replace with `Path.relative_to()` which correctly validates that the resolved path is actually inside the allowed directory tree. Fixes #888 --- nanobot/agent/tools/filesystem.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index 9c169e4..b87da11 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -13,8 +13,11 @@ def _resolve_path(path: str, workspace: Path | None = None, allowed_dir: Path | if not p.is_absolute() and workspace: p = workspace / p resolved = p.resolve() - if allowed_dir and not str(resolved).startswith(str(allowed_dir.resolve())): - raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}") + if allowed_dir: + try: + resolved.relative_to(allowed_dir.resolve()) + except ValueError: + raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}") return resolved From ef96619039c98ccb45c61481cf1b5203aa5864ac Mon Sep 17 00:00:00 2001 From: andienguyen-ecoligo Date: Sat, 21 Feb 2026 12:34:50 -0500 Subject: [PATCH 40/46] fix(slack): add exception handling to socket listener _handle_message() in _on_socket_request() had no try/except. If it throws (bus full, permission error, etc.), the exception propagates up and crashes the Socket Mode event loop, causing missed messages. Other channels like Telegram already have explicit error handlers. Fixes #895 --- nanobot/channels/slack.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index 4fc1f41..d1c5895 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -179,18 +179,21 @@ class SlackChannel(BaseChannel): except Exception as e: logger.debug("Slack reactions_add failed: {}", e) - await self._handle_message( - sender_id=sender_id, - chat_id=chat_id, - content=text, - metadata={ - "slack": { - "event": event, - "thread_ts": thread_ts, - "channel_type": channel_type, - } - }, - ) + try: + await self._handle_message( + sender_id=sender_id, + chat_id=chat_id, + content=text, + metadata={ + "slack": { + "event": event, + "thread_ts": thread_ts, + "channel_type": channel_type, + } + }, + ) + except Exception as e: + logger.error("Error handling Slack message from {}: {}", sender_id, e) def _is_allowed(self, sender_id: str, chat_id: str, channel_type: str) -> bool: if channel_type == "im": From 54a0f3d038e2a7f18315e1768a351010401cf6c2 Mon Sep 17 00:00:00 2001 From: andienguyen-ecoligo Date: Sat, 21 Feb 2026 12:35:21 -0500 Subject: [PATCH 41/46] fix(session): handle errors in legacy session migration shutil.move() in _load() can fail due to permissions, disk full, or concurrent access. Without error handling, the exception propagates up and prevents the session from loading entirely. Wrap in try/except so migration failures are logged as warnings and the session falls back to loading from the legacy path on next attempt. Fixes #863 --- nanobot/session/manager.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 18e23b2..19d4439 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -108,9 +108,12 @@ class SessionManager: if not path.exists(): legacy_path = self._get_legacy_session_path(key) if legacy_path.exists(): - import shutil - shutil.move(str(legacy_path), str(path)) - logger.info("Migrated session {} from legacy path", key) + try: + import shutil + shutil.move(str(legacy_path), str(path)) + logger.info("Migrated session {} from legacy path", key) + except Exception as e: + logger.warning("Failed to migrate session {}: {}", key, e) if not path.exists(): return None From ba66c6475025e8123e88732f5cec71e5b56b0b14 Mon Sep 17 00:00:00 2001 From: andienguyen-ecoligo Date: Sat, 21 Feb 2026 12:36:04 -0500 Subject: [PATCH 42/46] fix(email): evict oldest half of dedup set instead of clearing entirely When _processed_uids exceeds 100k entries, the entire set was cleared with .clear(), allowing all previously seen emails to be re-processed. Now evicts the oldest 50% of entries, keeping recent UIDs to prevent duplicate processing while still bounding memory usage. Fixes #890 --- nanobot/channels/email.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/email.py b/nanobot/channels/email.py index 1b6f46b..c90a14d 100644 --- a/nanobot/channels/email.py +++ b/nanobot/channels/email.py @@ -304,7 +304,9 @@ class EmailChannel(BaseChannel): self._processed_uids.add(uid) # mark_seen is the primary dedup; this set is a safety net if len(self._processed_uids) > self._MAX_PROCESSED_UIDS: - self._processed_uids.clear() + # Evict oldest half instead of clearing entirely + to_keep = list(self._processed_uids)[len(self._processed_uids) // 2:] + self._processed_uids = set(to_keep) if mark_seen: client.store(imap_id, "+FLAGS", "\\Seen") From b323087631561d4312affd17f57aa0643210a730 Mon Sep 17 00:00:00 2001 From: Yingwen Luo-LUOYW Date: Sun, 22 Feb 2026 12:42:33 +0800 Subject: [PATCH 43/46] feat(cli): add DingTalk, QQ, and Email to channels status output --- nanobot/cli/commands.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 6155463..f1f9b30 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -668,6 +668,33 @@ def channels_status(): slack_config ) + # DingTalk + dt = config.channels.dingtalk + dt_config = f"client_id: {dt.client_id[:10]}..." if dt.client_id else "[dim]not configured[/dim]" + table.add_row( + "DingTalk", + "βœ“" if dt.enabled else "βœ—", + dt_config + ) + + # QQ + qq = config.channels.qq + qq_config = f"app_id: {qq.app_id[:10]}..." if qq.app_id else "[dim]not configured[/dim]" + table.add_row( + "QQ", + "βœ“" if qq.enabled else "βœ—", + qq_config + ) + + # Email + em = config.channels.email + em_config = em.imap_host if em.imap_host else "[dim]not configured[/dim]" + table.add_row( + "Email", + "βœ“" if em.enabled else "βœ—", + em_config + ) + console.print(table) From b93b77a485d3a0c1632a5c3d590bc0b2a37c43bd Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 22 Feb 2026 15:38:19 +0000 Subject: [PATCH 44/46] fix(slack): use logger.exception to capture full traceback --- nanobot/channels/slack.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index d1c5895..b0f9bbb 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -192,8 +192,8 @@ class SlackChannel(BaseChannel): } }, ) - except Exception as e: - logger.error("Error handling Slack message from {}: {}", sender_id, e) + except Exception: + logger.exception("Error handling Slack message from {}", sender_id) def _is_allowed(self, sender_id: str, chat_id: str, channel_type: str) -> bool: if channel_type == "im": From 71de1899e6dcf9e5d01396b6a90e6a63486bc6de Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 22 Feb 2026 15:40:17 +0000 Subject: [PATCH 45/46] fix(session): use logger.exception and move import to top --- nanobot/session/manager.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 19d4439..5f23dc2 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -1,6 +1,7 @@ """Session management for conversation history.""" import json +import shutil from pathlib import Path from dataclasses import dataclass, field from datetime import datetime @@ -109,11 +110,10 @@ class SessionManager: legacy_path = self._get_legacy_session_path(key) if legacy_path.exists(): try: - import shutil shutil.move(str(legacy_path), str(path)) logger.info("Migrated session {} from legacy path", key) - except Exception as e: - logger.warning("Failed to migrate session {}: {}", key, e) + except Exception: + logger.exception("Failed to migrate session {}", key) if not path.exists(): return None From 4e8c8cc2274f1c5f505574e8fa7d5a5df790aff6 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 22 Feb 2026 15:48:49 +0000 Subject: [PATCH 46/46] fix(email): fix misleading comment and simplify uid eviction --- nanobot/channels/email.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/nanobot/channels/email.py b/nanobot/channels/email.py index c90a14d..5dc05fb 100644 --- a/nanobot/channels/email.py +++ b/nanobot/channels/email.py @@ -304,9 +304,8 @@ class EmailChannel(BaseChannel): self._processed_uids.add(uid) # mark_seen is the primary dedup; this set is a safety net if len(self._processed_uids) > self._MAX_PROCESSED_UIDS: - # Evict oldest half instead of clearing entirely - to_keep = list(self._processed_uids)[len(self._processed_uids) // 2:] - self._processed_uids = set(to_keep) + # Evict a random half to cap memory; mark_seen is the primary dedup + self._processed_uids = set(list(self._processed_uids)[len(self._processed_uids) // 2:]) if mark_seen: client.store(imap_id, "+FLAGS", "\\Seen")