From c0ad98650414aa22ad9d3364ded069e94e5f1479 Mon Sep 17 00:00:00 2001 From: spartan077 Date: Sat, 28 Feb 2026 13:44:22 +0530 Subject: [PATCH 01/84] fix: add message deduplication to WhatsApp channel Prevent infinite loops by tracking processed message IDs in WhatsApp channel. The bridge may send duplicate messages which caused the bot to respond repeatedly with the same generic message. Changes: - Add _processed_message_ids deque (max 2000) to track seen messages - Skip processing if message_id was already processed - Align WhatsApp dedup with other channels (Feishu, Email, Mochat, QQ) This fixes the issue where WhatsApp gets stuck in a loop sending identical responses repeatedly. Co-Authored-By: Claude Sonnet 4.6 --- nanobot/channels/whatsapp.py | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index f5fb52175..b171b6c9a 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -2,6 +2,7 @@ import asyncio import json +from collections import deque from typing import Any from loguru import logger @@ -15,18 +16,20 @@ from nanobot.config.schema import WhatsAppConfig class WhatsAppChannel(BaseChannel): """ WhatsApp channel that connects to a Node.js bridge. - + The bridge uses @whiskeysockets/baileys to handle the WhatsApp Web protocol. Communication between Python and Node.js is via WebSocket. """ - + name = "whatsapp" - + MAX_PROCESSED_MESSAGE_IDS = 2000 + def __init__(self, config: WhatsAppConfig, bus: MessageBus): super().__init__(config, bus) self.config: WhatsAppConfig = config self._ws = None self._connected = False + self._processed_message_ids: deque[str] = deque(maxlen=self.MAX_PROCESSED_MESSAGE_IDS) async def start(self) -> None: """Start the WhatsApp channel by connecting to the bridge.""" @@ -105,26 +108,35 @@ class WhatsAppChannel(BaseChannel): # Incoming message from WhatsApp # Deprecated by whatsapp: old phone number style typically: @s.whatspp.net pn = data.get("pn", "") - # New LID sytle typically: + # New LID sytle typically: sender = data.get("sender", "") content = data.get("content", "") - + message_id = data.get("id", "") + + # Dedup by message ID to prevent loops + if message_id and message_id in self._processed_message_ids: + logger.debug("Duplicate message {}, skipping", message_id) + return + + if message_id: + self._processed_message_ids.append(message_id) + # Extract just the phone number or lid as chat_id user_id = pn if pn else sender sender_id = user_id.split("@")[0] if "@" in user_id else user_id logger.info("Sender {}", sender) - + # Handle voice transcription if it's a voice message if content == "[Voice Message]": logger.info("Voice message received from {}, but direct download from bridge is not yet supported.", sender_id) content = "[Voice Message: Transcription not available for WhatsApp yet]" - + await self._handle_message( sender_id=sender_id, chat_id=sender, # Use full LID for replies content=content, metadata={ - "message_id": data.get("id"), + "message_id": message_id, "timestamp": data.get("timestamp"), "is_group": data.get("isGroup", False) } From 8410f859f734372f3a97cac413f847dc297b588d Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 28 Feb 2026 08:26:55 +0000 Subject: [PATCH 02/84] =?UTF-8?q?refactor:=20use=20WeakValueDictionary=20f?= =?UTF-8?q?or=20consolidation=20locks=20=E2=80=94=20auto-cleanup,=20no=20m?= =?UTF-8?q?anual=20pop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nanobot/agent/loop.py | 7 ++----- tests/test_consolidate_offset.py | 13 +++---------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index b605ae4a9..d8e5cad20 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio import json import re +import weakref from contextlib import AsyncExitStack from pathlib import Path from typing import TYPE_CHECKING, Any, Awaitable, Callable @@ -100,7 +101,7 @@ class AgentLoop: self._mcp_connecting = False self._consolidating: set[str] = set() # Session keys with consolidation in progress self._consolidation_tasks: set[asyncio.Task] = set() # Strong refs to in-flight tasks - self._consolidation_locks: dict[str, asyncio.Lock] = {} + self._consolidation_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary() self._active_tasks: dict[str, list[asyncio.Task]] = {} # session_key -> tasks self._processing_lock = asyncio.Lock() self._register_default_tools() @@ -373,8 +374,6 @@ class AgentLoop: ) finally: self._consolidating.discard(session.key) - if not lock.locked(): - self._consolidation_locks.pop(session.key, None) session.clear() self.sessions.save(session) @@ -396,8 +395,6 @@ class AgentLoop: await self._consolidate_memory(session) finally: self._consolidating.discard(session.key) - if not lock.locked(): - self._consolidation_locks.pop(session.key, None) _task = asyncio.current_task() if _task is not None: self._consolidation_tasks.discard(_task) diff --git a/tests/test_consolidate_offset.py b/tests/test_consolidate_offset.py index 675512406..a3213ddc8 100644 --- a/tests/test_consolidate_offset.py +++ b/tests/test_consolidate_offset.py @@ -786,10 +786,8 @@ class TestConsolidationDeduplicationGuard: ) @pytest.mark.asyncio - async def test_new_cleans_up_consolidation_lock_for_invalidated_session( - self, tmp_path: Path - ) -> None: - """/new should remove lock entry for fully invalidated session key.""" + async def test_new_clears_session_and_responds(self, tmp_path: Path) -> None: + """/new clears session and returns confirmation.""" from nanobot.agent.loop import AgentLoop from nanobot.bus.events import InboundMessage from nanobot.bus.queue import MessageBus @@ -801,7 +799,6 @@ class TestConsolidationDeduplicationGuard: loop = AgentLoop( bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10 ) - loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[])) loop.tools.get_definitions = MagicMock(return_value=[]) @@ -811,10 +808,6 @@ class TestConsolidationDeduplicationGuard: session.add_message("assistant", f"resp{i}") loop.sessions.save(session) - # Ensure lock exists before /new. - loop._consolidation_locks.setdefault(session.key, asyncio.Lock()) - assert session.key in loop._consolidation_locks - async def _ok_consolidate(sess, archive_all: bool = False) -> bool: return True @@ -825,4 +818,4 @@ class TestConsolidationDeduplicationGuard: assert response is not None assert "new session started" in response.content.lower() - assert session.key not in loop._consolidation_locks + assert loop.sessions.get_or_create("cli:test").messages == [] From 95ffe47e343b6411aa7a20b32cbfaf8aa369f749 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 28 Feb 2026 08:38:29 +0000 Subject: [PATCH 03/84] refactor: use OrderedDict for WhatsApp dedup, consistent with Feishu --- nanobot/channels/whatsapp.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index b171b6c9a..49d239065 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -2,7 +2,7 @@ import asyncio import json -from collections import deque +from collections import OrderedDict from typing import Any from loguru import logger @@ -22,14 +22,13 @@ class WhatsAppChannel(BaseChannel): """ name = "whatsapp" - MAX_PROCESSED_MESSAGE_IDS = 2000 def __init__(self, config: WhatsAppConfig, bus: MessageBus): super().__init__(config, bus) self.config: WhatsAppConfig = config self._ws = None self._connected = False - self._processed_message_ids: deque[str] = deque(maxlen=self.MAX_PROCESSED_MESSAGE_IDS) + self._processed_message_ids: OrderedDict[str, None] = OrderedDict() async def start(self) -> None: """Start the WhatsApp channel by connecting to the bridge.""" @@ -113,13 +112,12 @@ class WhatsAppChannel(BaseChannel): content = data.get("content", "") message_id = data.get("id", "") - # Dedup by message ID to prevent loops - if message_id and message_id in self._processed_message_ids: - logger.debug("Duplicate message {}, skipping", message_id) - return - if message_id: - self._processed_message_ids.append(message_id) + if message_id in self._processed_message_ids: + return + self._processed_message_ids[message_id] = None + while len(self._processed_message_ids) > 1000: + self._processed_message_ids.popitem(last=False) # Extract just the phone number or lid as chat_id user_id = pn if pn else sender From 52222a9f8475b64879d50f8925587206a3ffc774 Mon Sep 17 00:00:00 2001 From: fengxiaohu <975326527@qq.com> Date: Sat, 28 Feb 2026 18:46:15 +0800 Subject: [PATCH 04/84] fix(providers): allow reasoning_content in message history for thinking models --- 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 7402a2b0b..03a6c4dba 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -13,7 +13,7 @@ from nanobot.providers.registry import find_by_model, find_gateway # Standard OpenAI chat-completion message keys; extras (e.g. reasoning_content) are stripped for strict providers. -_ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name"}) +_ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content"}) class LiteLLMProvider(LLMProvider): From cfc55d626afa86a9bdf4c120d1ad8882a063244c Mon Sep 17 00:00:00 2001 From: "siyuan.qsy" Date: Sat, 28 Feb 2026 19:00:22 +0800 Subject: [PATCH 05/84] feat(dingtalk): send images as image messages, keep files as attachments --- nanobot/channels/dingtalk.py | 290 +++++++++++++++++++++++++++++++---- 1 file changed, 263 insertions(+), 27 deletions(-) diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py index 09c771495..53a9bb85c 100644 --- a/nanobot/channels/dingtalk.py +++ b/nanobot/channels/dingtalk.py @@ -2,8 +2,12 @@ import asyncio import json +import mimetypes +import os import time +from pathlib import Path from typing import Any +from urllib.parse import unquote, urlparse from loguru import logger import httpx @@ -96,6 +100,9 @@ class DingTalkChannel(BaseChannel): """ name = "dingtalk" + _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"} + _AUDIO_EXTS = {".amr", ".mp3", ".wav", ".ogg", ".m4a", ".aac"} + _VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm"} def __init__(self, config: DingTalkConfig, bus: MessageBus): super().__init__(config, bus) @@ -191,40 +198,269 @@ class DingTalkChannel(BaseChannel): logger.error("Failed to get DingTalk access token: {}", e) return None + @staticmethod + def _is_http_url(value: str) -> bool: + low = value.lower() + return low.startswith("http://") or low.startswith("https://") + + def _guess_upload_type(self, media_ref: str) -> str: + parsed = urlparse(media_ref) + path = parsed.path if parsed.scheme else media_ref + ext = Path(path).suffix.lower() + if ext in self._IMAGE_EXTS: + return "image" + if ext in self._AUDIO_EXTS: + return "voice" + if ext in self._VIDEO_EXTS: + return "video" + return "file" + + def _guess_filename(self, media_ref: str, upload_type: str) -> str: + parsed = urlparse(media_ref) + path = parsed.path if parsed.scheme else media_ref + name = os.path.basename(path) + if name: + return name + fallback = { + "image": "image.jpg", + "voice": "audio.amr", + "video": "video.mp4", + "file": "file.bin", + } + return fallback.get(upload_type, "file.bin") + + async def _read_media_bytes( + self, + media_ref: str, + ) -> tuple[bytes | None, str | None, str | None]: + if not media_ref: + return None, None, None + + if self._is_http_url(media_ref): + if not self._http: + return None, None, None + try: + resp = await self._http.get(media_ref, follow_redirects=True) + if resp.status_code >= 400: + logger.warning( + "DingTalk media download failed status={} ref={}", + resp.status_code, + media_ref, + ) + return None, None, None + content_type = (resp.headers.get("content-type") or "").split(";")[0].strip() + filename = self._guess_filename(media_ref, self._guess_upload_type(media_ref)) + return resp.content, filename, content_type or None + except Exception as e: + logger.error("DingTalk media download error ref={} err={}", media_ref, e) + return None, None, None + + try: + if media_ref.startswith("file://"): + parsed = urlparse(media_ref) + local_path = Path(unquote(parsed.path)) + else: + local_path = Path(os.path.expanduser(media_ref)) + if not local_path.is_file(): + logger.warning("DingTalk media file not found: {}", local_path) + return None, None, None + data = await asyncio.to_thread(local_path.read_bytes) + content_type = mimetypes.guess_type(local_path.name)[0] + return data, local_path.name, content_type + except Exception as e: + logger.error("DingTalk media read error ref={} err={}", media_ref, e) + return None, None, None + + async def _upload_media( + self, + token: str, + data: bytes, + media_type: str, + filename: str, + content_type: str | None, + ) -> str | None: + if not self._http: + return None + url = f"https://oapi.dingtalk.com/media/upload?access_token={token}&type={media_type}" + mime = content_type or mimetypes.guess_type(filename)[0] or "application/octet-stream" + files = {"media": (filename, data, mime)} + + try: + resp = await self._http.post(url, files=files) + text = resp.text + try: + result = resp.json() + except Exception: + result = {} + if resp.status_code >= 400: + logger.error( + "DingTalk media upload failed status={} type={} body={}", + resp.status_code, + media_type, + text[:500], + ) + return None + errcode = result.get("errcode", 0) + if errcode != 0: + logger.error( + "DingTalk media upload api error type={} errcode={} body={}", + media_type, + errcode, + text[:500], + ) + return None + media_id = ( + result.get("media_id") + or result.get("mediaId") + or (result.get("result") or {}).get("media_id") + or (result.get("result") or {}).get("mediaId") + ) + if not media_id: + logger.error("DingTalk media upload missing media_id body={}", text[:500]) + return None + return str(media_id) + except Exception as e: + logger.error("DingTalk media upload error type={} err={}", media_type, e) + return None + + async def _send_batch_message( + self, + token: str, + chat_id: str, + msg_key: str, + msg_param: dict[str, Any], + ) -> bool: + if not self._http: + logger.warning("DingTalk HTTP client not initialized, cannot send") + return False + + url = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend" + headers = {"x-acs-dingtalk-access-token": token} + payload = { + "robotCode": self.config.client_id, + "userIds": [chat_id], + "msgKey": msg_key, + "msgParam": json.dumps(msg_param, ensure_ascii=False), + } + + try: + resp = await self._http.post(url, json=payload, headers=headers) + body = resp.text + if resp.status_code != 200: + logger.error( + "DingTalk send failed msgKey={} status={} body={}", + msg_key, + resp.status_code, + body[:500], + ) + return False + try: + result = resp.json() + except Exception: + result = {} + errcode = result.get("errcode") + if errcode not in (None, 0): + logger.error( + "DingTalk send api error msgKey={} errcode={} body={}", + msg_key, + errcode, + body[:500], + ) + return False + logger.debug("DingTalk message sent to {} with msgKey={}", chat_id, msg_key) + return True + except Exception as e: + logger.error("Error sending DingTalk message msgKey={} err={}", msg_key, e) + return False + + async def _send_markdown_text(self, token: str, chat_id: str, content: str) -> bool: + return await self._send_batch_message( + token, + chat_id, + "sampleMarkdown", + {"text": content, "title": "Nanobot Reply"}, + ) + + async def _send_media_ref(self, token: str, chat_id: str, media_ref: str) -> bool: + media_ref = (media_ref or "").strip() + if not media_ref: + return True + + upload_type = self._guess_upload_type(media_ref) + if upload_type == "image" and self._is_http_url(media_ref): + ok = await self._send_batch_message( + token, + chat_id, + "sampleImageMsg", + {"photoURL": media_ref}, + ) + if ok: + return True + logger.warning("DingTalk image url send failed, trying upload fallback: {}", media_ref) + + data, filename, content_type = await self._read_media_bytes(media_ref) + if not data: + logger.error("DingTalk media read failed: {}", media_ref) + return False + + filename = filename or self._guess_filename(media_ref, upload_type) + file_type = Path(filename).suffix.lower().lstrip(".") + if not file_type: + guessed = mimetypes.guess_extension(content_type or "") + file_type = (guessed or ".bin").lstrip(".") + if file_type == "jpeg": + file_type = "jpg" + + media_id = await self._upload_media( + token=token, + data=data, + media_type=upload_type, + filename=filename, + content_type=content_type, + ) + if not media_id: + return False + + if upload_type == "image": + # Verified in production: sampleImageMsg accepts media_id in photoURL. + ok = await self._send_batch_message( + token, + chat_id, + "sampleImageMsg", + {"photoURL": media_id}, + ) + if ok: + return True + logger.warning("DingTalk image media_id send failed, falling back to file: {}", media_ref) + + return await self._send_batch_message( + token, + chat_id, + "sampleFile", + {"mediaId": media_id, "fileName": filename, "fileType": file_type}, + ) + async def send(self, msg: OutboundMessage) -> None: """Send a message through DingTalk.""" token = await self._get_access_token() if not token: return - # oToMessages/batchSend: sends to individual users (private chat) - # https://open.dingtalk.com/document/orgapp/robot-batch-send-messages - url = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend" + if msg.content and msg.content.strip(): + await self._send_markdown_text(token, msg.chat_id, msg.content.strip()) - headers = {"x-acs-dingtalk-access-token": token} - - data = { - "robotCode": self.config.client_id, - "userIds": [msg.chat_id], # chat_id is the user's staffId - "msgKey": "sampleMarkdown", - "msgParam": json.dumps({ - "text": msg.content, - "title": "Nanobot Reply", - }, ensure_ascii=False), - } - - if not self._http: - logger.warning("DingTalk HTTP client not initialized, cannot send") - return - - try: - resp = await self._http.post(url, json=data, headers=headers) - if resp.status_code != 200: - logger.error("DingTalk send failed: {}", resp.text) - else: - logger.debug("DingTalk message sent to {}", msg.chat_id) - except Exception as e: - logger.error("Error sending DingTalk message: {}", e) + for media_ref in msg.media or []: + ok = await self._send_media_ref(token, msg.chat_id, media_ref) + if ok: + continue + logger.error("DingTalk media send failed for {}", media_ref) + # Send visible fallback so failures are observable by the user. + filename = self._guess_filename(media_ref, self._guess_upload_type(media_ref)) + await self._send_markdown_text( + token, + msg.chat_id, + f"[Attachment send failed: {filename}]", + ) async def _on_message(self, content: str, sender_id: str, sender_name: str) -> None: """Handle incoming message (called by NanobotDingTalkHandler). From 977ca725f261bfb665ecc5ec11493865c3c795ed Mon Sep 17 00:00:00 2001 From: JK_Lu <6513279+jk_lu@user.noreply.gitee.com> Date: Sat, 28 Feb 2026 20:55:43 +0800 Subject: [PATCH 06/84] style: unify code formatting and import order - Remove trailing whitespace and normalize blank lines - Unify string quotes and line breaks for long lines - Sort imports alphabetically across modules --- nanobot/agent/__init__.py | 2 +- nanobot/agent/context.py | 26 ++-- nanobot/agent/skills.py | 78 +++++------ nanobot/agent/subagent.py | 51 +++---- nanobot/agent/tools/base.py | 28 ++-- nanobot/agent/tools/cron.py | 48 +++---- nanobot/agent/tools/filesystem.py | 93 +++++-------- nanobot/agent/tools/registry.py | 22 +-- nanobot/agent/tools/shell.py | 8 +- nanobot/agent/tools/spawn.py | 14 +- nanobot/agent/tools/web.py | 26 ++-- nanobot/bus/events.py | 6 +- nanobot/channels/base.py | 40 +++--- nanobot/channels/dingtalk.py | 8 +- nanobot/channels/discord.py | 1 - nanobot/channels/feishu.py | 85 ++++++------ nanobot/channels/manager.py | 46 +++--- nanobot/channels/matrix.py | 20 ++- nanobot/channels/slack.py | 3 +- nanobot/channels/telegram.py | 133 +++++++++--------- nanobot/channels/whatsapp.py | 39 +++--- nanobot/cli/commands.py | 154 +++++++++++---------- nanobot/config/__init__.py | 2 +- nanobot/config/schema.py | 2 +- nanobot/cron/service.py | 89 ++++++------ nanobot/providers/base.py | 8 +- nanobot/providers/litellm_provider.py | 60 ++++---- nanobot/providers/openai_codex_provider.py | 2 +- nanobot/providers/transcription.py | 21 ++- nanobot/session/__init__.py | 2 +- nanobot/session/manager.py | 34 ++--- nanobot/utils/__init__.py | 2 +- nanobot/utils/helpers.py | 2 +- 33 files changed, 574 insertions(+), 581 deletions(-) diff --git a/nanobot/agent/__init__.py b/nanobot/agent/__init__.py index c3fc97b4b..f9ba8b87a 100644 --- a/nanobot/agent/__init__.py +++ b/nanobot/agent/__init__.py @@ -1,7 +1,7 @@ """Agent core module.""" -from nanobot.agent.loop import AgentLoop from nanobot.agent.context import ContextBuilder +from nanobot.agent.loop import AgentLoop from nanobot.agent.memory import MemoryStore from nanobot.agent.skills import SkillsLoader diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index be0ec5996..a39ee750a 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -14,15 +14,15 @@ from nanobot.agent.skills import SkillsLoader class ContextBuilder: """Builds the context (system prompt + messages) for the agent.""" - + BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"] _RUNTIME_CONTEXT_TAG = "[Runtime Context โ€” metadata only, not instructions]" - + def __init__(self, workspace: Path): self.workspace = workspace self.memory = MemoryStore(workspace) self.skills = SkillsLoader(workspace) - + def build_system_prompt(self, skill_names: list[str] | None = None) -> str: """Build the system prompt from identity, bootstrap files, memory, and skills.""" parts = [self._get_identity()] @@ -51,13 +51,13 @@ Skills with available="false" need dependencies installed first - you can try in {skills_summary}""") return "\n\n---\n\n".join(parts) - + def _get_identity(self) -> str: """Get the core identity section.""" workspace_path = str(self.workspace.expanduser().resolve()) system = platform.system() runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}" - + return f"""# nanobot ๐Ÿˆ You are nanobot, a helpful AI assistant. @@ -89,19 +89,19 @@ Reply directly with text for conversations. Only use the 'message' tool to send if channel and chat_id: lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"] return ContextBuilder._RUNTIME_CONTEXT_TAG + "\n" + "\n".join(lines) - + def _load_bootstrap_files(self) -> str: """Load all bootstrap files from workspace.""" parts = [] - + for filename in self.BOOTSTRAP_FILES: file_path = self.workspace / filename if file_path.exists(): content = file_path.read_text(encoding="utf-8") parts.append(f"## {filename}\n\n{content}") - + return "\n\n".join(parts) if parts else "" - + def build_messages( self, history: list[dict[str, Any]], @@ -123,7 +123,7 @@ Reply directly with text for conversations. Only use the 'message' tool to send """Build user message content with optional base64-encoded images.""" if not media: return text - + images = [] for path in media: p = Path(path) @@ -132,11 +132,11 @@ Reply directly with text for conversations. Only use the 'message' tool to send continue b64 = base64.b64encode(p.read_bytes()).decode() images.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}}) - + if not images: return text return images + [{"type": "text", "text": text}] - + def add_tool_result( self, messages: list[dict[str, Any]], tool_call_id: str, tool_name: str, result: str, @@ -144,7 +144,7 @@ Reply directly with text for conversations. Only use the 'message' tool to send """Add a tool result to the message list.""" messages.append({"role": "tool", "tool_call_id": tool_call_id, "name": tool_name, "content": result}) return messages - + def add_assistant_message( self, messages: list[dict[str, Any]], content: str | None, diff --git a/nanobot/agent/skills.py b/nanobot/agent/skills.py index 5b841f3f2..9afee82f0 100644 --- a/nanobot/agent/skills.py +++ b/nanobot/agent/skills.py @@ -13,28 +13,28 @@ BUILTIN_SKILLS_DIR = Path(__file__).parent.parent / "skills" class SkillsLoader: """ Loader for agent skills. - + Skills are markdown files (SKILL.md) that teach the agent how to use specific tools or perform certain tasks. """ - + def __init__(self, workspace: Path, builtin_skills_dir: Path | None = None): self.workspace = workspace self.workspace_skills = workspace / "skills" self.builtin_skills = builtin_skills_dir or BUILTIN_SKILLS_DIR - + def list_skills(self, filter_unavailable: bool = True) -> list[dict[str, str]]: """ List all available skills. - + Args: filter_unavailable: If True, filter out skills with unmet requirements. - + Returns: List of skill info dicts with 'name', 'path', 'source'. """ skills = [] - + # Workspace skills (highest priority) if self.workspace_skills.exists(): for skill_dir in self.workspace_skills.iterdir(): @@ -42,7 +42,7 @@ class SkillsLoader: skill_file = skill_dir / "SKILL.md" if skill_file.exists(): skills.append({"name": skill_dir.name, "path": str(skill_file), "source": "workspace"}) - + # Built-in skills if self.builtin_skills and self.builtin_skills.exists(): for skill_dir in self.builtin_skills.iterdir(): @@ -50,19 +50,19 @@ class SkillsLoader: skill_file = skill_dir / "SKILL.md" if skill_file.exists() and not any(s["name"] == skill_dir.name for s in skills): skills.append({"name": skill_dir.name, "path": str(skill_file), "source": "builtin"}) - + # Filter by requirements if filter_unavailable: return [s for s in skills if self._check_requirements(self._get_skill_meta(s["name"]))] return skills - + def load_skill(self, name: str) -> str | None: """ Load a skill by name. - + Args: name: Skill name (directory name). - + Returns: Skill content or None if not found. """ @@ -70,22 +70,22 @@ class SkillsLoader: workspace_skill = self.workspace_skills / name / "SKILL.md" if workspace_skill.exists(): return workspace_skill.read_text(encoding="utf-8") - + # Check built-in if self.builtin_skills: builtin_skill = self.builtin_skills / name / "SKILL.md" if builtin_skill.exists(): return builtin_skill.read_text(encoding="utf-8") - + return None - + def load_skills_for_context(self, skill_names: list[str]) -> str: """ Load specific skills for inclusion in agent context. - + Args: skill_names: List of skill names to load. - + Returns: Formatted skills content. """ @@ -95,26 +95,26 @@ class SkillsLoader: if content: content = self._strip_frontmatter(content) parts.append(f"### Skill: {name}\n\n{content}") - + return "\n\n---\n\n".join(parts) if parts else "" - + def build_skills_summary(self) -> str: """ Build a summary of all skills (name, description, path, availability). - + This is used for progressive loading - the agent can read the full skill content using read_file when needed. - + Returns: XML-formatted skills summary. """ all_skills = self.list_skills(filter_unavailable=False) if not all_skills: return "" - + def escape_xml(s: str) -> str: return s.replace("&", "&").replace("<", "<").replace(">", ">") - + lines = [""] for s in all_skills: name = escape_xml(s["name"]) @@ -122,23 +122,23 @@ class SkillsLoader: desc = escape_xml(self._get_skill_description(s["name"])) skill_meta = self._get_skill_meta(s["name"]) available = self._check_requirements(skill_meta) - + lines.append(f" ") lines.append(f" {name}") lines.append(f" {desc}") lines.append(f" {path}") - + # Show missing requirements for unavailable skills if not available: missing = self._get_missing_requirements(skill_meta) if missing: lines.append(f" {escape_xml(missing)}") - - lines.append(f" ") + + lines.append(" ") lines.append("") - + return "\n".join(lines) - + def _get_missing_requirements(self, skill_meta: dict) -> str: """Get a description of missing requirements.""" missing = [] @@ -150,14 +150,14 @@ class SkillsLoader: if not os.environ.get(env): missing.append(f"ENV: {env}") return ", ".join(missing) - + def _get_skill_description(self, name: str) -> str: """Get the description of a skill from its frontmatter.""" meta = self.get_skill_metadata(name) if meta and meta.get("description"): return meta["description"] return name # Fallback to skill name - + def _strip_frontmatter(self, content: str) -> str: """Remove YAML frontmatter from markdown content.""" if content.startswith("---"): @@ -165,7 +165,7 @@ class SkillsLoader: if match: return content[match.end():].strip() return content - + def _parse_nanobot_metadata(self, raw: str) -> dict: """Parse skill metadata JSON from frontmatter (supports nanobot and openclaw keys).""" try: @@ -173,7 +173,7 @@ class SkillsLoader: return data.get("nanobot", data.get("openclaw", {})) if isinstance(data, dict) else {} except (json.JSONDecodeError, TypeError): return {} - + def _check_requirements(self, skill_meta: dict) -> bool: """Check if skill requirements are met (bins, env vars).""" requires = skill_meta.get("requires", {}) @@ -184,12 +184,12 @@ class SkillsLoader: if not os.environ.get(env): return False return True - + def _get_skill_meta(self, name: str) -> dict: """Get nanobot metadata for a skill (cached in frontmatter).""" meta = self.get_skill_metadata(name) or {} return self._parse_nanobot_metadata(meta.get("metadata", "")) - + def get_always_skills(self) -> list[str]: """Get skills marked as always=true that meet requirements.""" result = [] @@ -199,21 +199,21 @@ class SkillsLoader: if skill_meta.get("always") or meta.get("always"): result.append(s["name"]) return result - + def get_skill_metadata(self, name: str) -> dict | None: """ Get metadata from a skill's frontmatter. - + Args: name: Skill name. - + Returns: Metadata dict or None. """ content = self.load_skill(name) if not content: return None - + if content.startswith("---"): match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL) if match: @@ -224,5 +224,5 @@ class SkillsLoader: key, value = line.split(":", 1) metadata[key.strip()] = value.strip().strip('"\'') return metadata - + return None diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 337796c94..5aff25c63 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -8,18 +8,19 @@ from typing import Any from loguru import logger +from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool +from nanobot.agent.tools.registry import ToolRegistry +from nanobot.agent.tools.shell import ExecTool +from nanobot.agent.tools.web import WebFetchTool, WebSearchTool from nanobot.bus.events import InboundMessage from nanobot.bus.queue import MessageBus +from nanobot.config.schema import ExecToolConfig from nanobot.providers.base import LLMProvider -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 class SubagentManager: """Manages background subagent execution.""" - + def __init__( self, provider: LLMProvider, @@ -44,7 +45,7 @@ class SubagentManager: self.restrict_to_workspace = restrict_to_workspace self._running_tasks: dict[str, asyncio.Task[None]] = {} self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...} - + async def spawn( self, task: str, @@ -73,10 +74,10 @@ class SubagentManager: del self._session_tasks[session_key] bg_task.add_done_callback(_cleanup) - + logger.info("Spawned subagent [{}]: {}", task_id, display_label) return f"Subagent [{display_label}] started (id: {task_id}). I'll notify you when it completes." - + async def _run_subagent( self, task_id: str, @@ -86,7 +87,7 @@ class SubagentManager: ) -> None: """Execute the subagent task and announce the result.""" logger.info("Subagent [{}] starting task: {}", task_id, label) - + try: # Build subagent tools (no message tool, no spawn tool) tools = ToolRegistry() @@ -103,22 +104,22 @@ class SubagentManager: )) tools.register(WebSearchTool(api_key=self.brave_api_key)) tools.register(WebFetchTool()) - + # Build messages with subagent-specific prompt system_prompt = self._build_subagent_prompt(task) messages: list[dict[str, Any]] = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": task}, ] - + # Run agent loop (limited iterations) max_iterations = 15 iteration = 0 final_result: str | None = None - + while iteration < max_iterations: iteration += 1 - + response = await self.provider.chat( messages=messages, tools=tools.get_definitions(), @@ -126,7 +127,7 @@ class SubagentManager: temperature=self.temperature, max_tokens=self.max_tokens, ) - + if response.has_tool_calls: # Add assistant message with tool calls tool_call_dicts = [ @@ -145,7 +146,7 @@ class SubagentManager: "content": response.content or "", "tool_calls": tool_call_dicts, }) - + # Execute tools for tool_call in response.tool_calls: args_str = json.dumps(tool_call.arguments, ensure_ascii=False) @@ -160,18 +161,18 @@ class SubagentManager: else: final_result = response.content break - + if final_result is None: final_result = "Task completed but no final response was generated." - + logger.info("Subagent [{}] completed successfully", task_id) await self._announce_result(task_id, label, task, final_result, origin, "ok") - + except Exception as e: error_msg = f"Error: {str(e)}" logger.error("Subagent [{}] failed: {}", task_id, e) await self._announce_result(task_id, label, task, error_msg, origin, "error") - + async def _announce_result( self, task_id: str, @@ -183,7 +184,7 @@ class SubagentManager: ) -> None: """Announce the subagent result to the main agent via the message bus.""" status_text = "completed successfully" if status == "ok" else "failed" - + announce_content = f"""[Subagent '{label}' {status_text}] Task: {task} @@ -192,7 +193,7 @@ Result: {result} Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not mention technical details like "subagent" or task IDs.""" - + # Inject as system message to trigger main agent msg = InboundMessage( channel="system", @@ -200,14 +201,14 @@ Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not men chat_id=f"{origin['channel']}:{origin['chat_id']}", content=announce_content, ) - + await self.bus.publish_inbound(msg) logger.debug("Subagent [{}] announced result to {}:{}", task_id, origin['channel'], origin['chat_id']) - + def _build_subagent_prompt(self, task: str) -> str: """Build a focused system prompt for the subagent.""" - from datetime import datetime import time as _time + from datetime import datetime now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)") tz = _time.strftime("%Z") or "UTC" @@ -240,7 +241,7 @@ Your workspace is at: {self.workspace} Skills are available at: {self.workspace}/skills/ (read SKILL.md files as needed) When you have completed the task, provide a clear summary of your findings or actions.""" - + async def cancel_by_session(self, session_key: str) -> int: """Cancel all subagents for the given session. Returns count cancelled.""" tasks = [self._running_tasks[tid] for tid in self._session_tasks.get(session_key, []) diff --git a/nanobot/agent/tools/base.py b/nanobot/agent/tools/base.py index ca9bcc2ad..8dd82c783 100644 --- a/nanobot/agent/tools/base.py +++ b/nanobot/agent/tools/base.py @@ -7,11 +7,11 @@ from typing import Any class Tool(ABC): """ Abstract base class for agent tools. - + Tools are capabilities that the agent can use to interact with the environment, such as reading files, executing commands, etc. """ - + _TYPE_MAP = { "string": str, "integer": int, @@ -20,33 +20,33 @@ class Tool(ABC): "array": list, "object": dict, } - + @property @abstractmethod def name(self) -> str: """Tool name used in function calls.""" pass - + @property @abstractmethod def description(self) -> str: """Description of what the tool does.""" pass - + @property @abstractmethod def parameters(self) -> dict[str, Any]: """JSON Schema for tool parameters.""" pass - + @abstractmethod async def execute(self, **kwargs: Any) -> str: """ Execute the tool with given parameters. - + Args: **kwargs: Tool-specific parameters. - + Returns: String result of the tool execution. """ @@ -63,7 +63,7 @@ class Tool(ABC): t, label = schema.get("type"), path or "parameter" if t in self._TYPE_MAP and not isinstance(val, self._TYPE_MAP[t]): return [f"{label} should be {t}"] - + errors = [] if "enum" in schema and val not in schema["enum"]: errors.append(f"{label} must be one of {schema['enum']}") @@ -84,12 +84,14 @@ class Tool(ABC): errors.append(f"missing required {path + '.' + k if path else k}") for k, v in val.items(): if k in props: - errors.extend(self._validate(v, props[k], path + '.' + k if path else k)) + errors.extend(self._validate(v, props[k], path + "." + k if path else k)) if t == "array" and "items" in schema: for i, item in enumerate(val): - errors.extend(self._validate(item, schema["items"], f"{path}[{i}]" if path else f"[{i}]")) + errors.extend( + self._validate(item, schema["items"], f"{path}[{i}]" if path else f"[{i}]") + ) return errors - + def to_schema(self) -> dict[str, Any]: """Convert tool to OpenAI function schema format.""" return { @@ -98,5 +100,5 @@ class Tool(ABC): "name": self.name, "description": self.description, "parameters": self.parameters, - } + }, } diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index b10e34bb2..fe1dce6d8 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -9,25 +9,25 @@ from nanobot.cron.types import CronSchedule class CronTool(Tool): """Tool to schedule reminders and recurring tasks.""" - + def __init__(self, cron_service: CronService): self._cron = cron_service self._channel = "" self._chat_id = "" - + def set_context(self, channel: str, chat_id: str) -> None: """Set the current session context for delivery.""" self._channel = channel self._chat_id = chat_id - + @property def name(self) -> str: return "cron" - + @property def description(self) -> str: return "Schedule reminders and recurring tasks. Actions: add, list, remove." - + @property def parameters(self) -> dict[str, Any]: return { @@ -36,36 +36,30 @@ class CronTool(Tool): "action": { "type": "string", "enum": ["add", "list", "remove"], - "description": "Action to perform" - }, - "message": { - "type": "string", - "description": "Reminder message (for add)" + "description": "Action to perform", }, + "message": {"type": "string", "description": "Reminder message (for add)"}, "every_seconds": { "type": "integer", - "description": "Interval in seconds (for recurring tasks)" + "description": "Interval in seconds (for recurring tasks)", }, "cron_expr": { "type": "string", - "description": "Cron expression like '0 9 * * *' (for scheduled tasks)" + "description": "Cron expression like '0 9 * * *' (for scheduled tasks)", }, "tz": { "type": "string", - "description": "IANA timezone for cron expressions (e.g. 'America/Vancouver')" + "description": "IANA timezone for cron expressions (e.g. 'America/Vancouver')", }, "at": { "type": "string", - "description": "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00')" + "description": "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00')", }, - "job_id": { - "type": "string", - "description": "Job ID (for remove)" - } + "job_id": {"type": "string", "description": "Job ID (for remove)"}, }, - "required": ["action"] + "required": ["action"], } - + async def execute( self, action: str, @@ -75,7 +69,7 @@ class CronTool(Tool): tz: str | None = None, at: str | None = None, job_id: str | None = None, - **kwargs: Any + **kwargs: Any, ) -> str: if action == "add": return self._add_job(message, every_seconds, cron_expr, tz, at) @@ -84,7 +78,7 @@ class CronTool(Tool): elif action == "remove": return self._remove_job(job_id) return f"Unknown action: {action}" - + def _add_job( self, message: str, @@ -101,11 +95,12 @@ class CronTool(Tool): return "Error: tz can only be used with cron_expr" if tz: from zoneinfo import ZoneInfo + try: ZoneInfo(tz) except (KeyError, Exception): return f"Error: unknown timezone '{tz}'" - + # Build schedule delete_after = False if every_seconds: @@ -114,13 +109,14 @@ class CronTool(Tool): schedule = CronSchedule(kind="cron", expr=cron_expr, tz=tz) elif at: from datetime import datetime + dt = datetime.fromisoformat(at) at_ms = int(dt.timestamp() * 1000) schedule = CronSchedule(kind="at", at_ms=at_ms) delete_after = True else: return "Error: either every_seconds, cron_expr, or at is required" - + job = self._cron.add_job( name=message[:30], schedule=schedule, @@ -131,14 +127,14 @@ class CronTool(Tool): delete_after_run=delete_after, ) return f"Created job '{job.name}' (id: {job.id})" - + def _list_jobs(self) -> str: jobs = self._cron.list_jobs() if not jobs: return "No scheduled jobs." lines = [f"- {j.name} (id: {j.id}, {j.schedule.kind})" for j in jobs] return "Scheduled jobs:\n" + "\n".join(lines) - + def _remove_job(self, job_id: str | None) -> str: if not job_id: return "Error: job_id is required for remove" diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index b87da116d..bbdd49c2d 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -7,7 +7,9 @@ from typing import Any from nanobot.agent.tools.base import Tool -def _resolve_path(path: str, workspace: Path | None = None, allowed_dir: Path | None = None) -> Path: +def _resolve_path( + path: str, workspace: Path | None = None, allowed_dir: Path | None = None +) -> Path: """Resolve path against workspace (if relative) and enforce directory restriction.""" p = Path(path).expanduser() if not p.is_absolute() and workspace: @@ -31,24 +33,19 @@ class ReadFileTool(Tool): @property def name(self) -> str: return "read_file" - + @property def description(self) -> str: return "Read the contents of a file at the given path." - + @property def parameters(self) -> dict[str, Any]: return { "type": "object", - "properties": { - "path": { - "type": "string", - "description": "The file path to read" - } - }, - "required": ["path"] + "properties": {"path": {"type": "string", "description": "The file path to read"}}, + "required": ["path"], } - + async def execute(self, path: str, **kwargs: Any) -> str: try: file_path = _resolve_path(path, self._workspace, self._allowed_dir) @@ -75,28 +72,22 @@ class WriteFileTool(Tool): @property def name(self) -> str: return "write_file" - + @property def description(self) -> str: return "Write content to a file at the given path. Creates parent directories if needed." - + @property def parameters(self) -> dict[str, Any]: return { "type": "object", "properties": { - "path": { - "type": "string", - "description": "The file path to write to" - }, - "content": { - "type": "string", - "description": "The content to write" - } + "path": {"type": "string", "description": "The file path to write to"}, + "content": {"type": "string", "description": "The content to write"}, }, - "required": ["path", "content"] + "required": ["path", "content"], } - + async def execute(self, path: str, content: str, **kwargs: Any) -> str: try: file_path = _resolve_path(path, self._workspace, self._allowed_dir) @@ -119,32 +110,23 @@ class EditFileTool(Tool): @property def name(self) -> str: return "edit_file" - + @property def description(self) -> str: return "Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file." - + @property def parameters(self) -> dict[str, Any]: return { "type": "object", "properties": { - "path": { - "type": "string", - "description": "The file path to edit" - }, - "old_text": { - "type": "string", - "description": "The exact text to find and replace" - }, - "new_text": { - "type": "string", - "description": "The text to replace with" - } + "path": {"type": "string", "description": "The file path to edit"}, + "old_text": {"type": "string", "description": "The exact text to find and replace"}, + "new_text": {"type": "string", "description": "The text to replace with"}, }, - "required": ["path", "old_text", "new_text"] + "required": ["path", "old_text", "new_text"], } - + async def execute(self, path: str, old_text: str, new_text: str, **kwargs: Any) -> str: try: file_path = _resolve_path(path, self._workspace, self._allowed_dir) @@ -184,13 +166,19 @@ class EditFileTool(Tool): best_ratio, best_start = ratio, i if best_ratio > 0.5: - 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 = "\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="", + ) + ) 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." + return ( + f"Error: old_text not found in {path}. No similar text found. Verify the file content." + ) class ListDirTool(Tool): @@ -203,24 +191,19 @@ class ListDirTool(Tool): @property def name(self) -> str: return "list_dir" - + @property def description(self) -> str: return "List the contents of a directory." - + @property def parameters(self) -> dict[str, Any]: return { "type": "object", - "properties": { - "path": { - "type": "string", - "description": "The directory path to list" - } - }, - "required": ["path"] + "properties": {"path": {"type": "string", "description": "The directory path to list"}}, + "required": ["path"], } - + async def execute(self, path: str, **kwargs: Any) -> str: try: dir_path = _resolve_path(path, self._workspace, self._allowed_dir) diff --git a/nanobot/agent/tools/registry.py b/nanobot/agent/tools/registry.py index 3af4aef58..5d36e52cd 100644 --- a/nanobot/agent/tools/registry.py +++ b/nanobot/agent/tools/registry.py @@ -8,33 +8,33 @@ from nanobot.agent.tools.base import Tool class ToolRegistry: """ Registry for agent tools. - + Allows dynamic registration and execution of tools. """ - + def __init__(self): self._tools: dict[str, Tool] = {} - + def register(self, tool: Tool) -> None: """Register a tool.""" self._tools[tool.name] = tool - + def unregister(self, name: str) -> None: """Unregister a tool by name.""" self._tools.pop(name, None) - + def get(self, name: str) -> Tool | None: """Get a tool by name.""" return self._tools.get(name) - + def has(self, name: str) -> bool: """Check if a tool is registered.""" return name in self._tools - + def get_definitions(self) -> list[dict[str, Any]]: """Get all tool definitions in OpenAI format.""" return [tool.to_schema() for tool in self._tools.values()] - + async def execute(self, name: str, params: dict[str, Any]) -> str: """Execute a tool by name with given parameters.""" _HINT = "\n\n[Analyze the error above and try a different approach.]" @@ -53,14 +53,14 @@ class ToolRegistry: return result except Exception as e: return f"Error executing {name}: {str(e)}" + _HINT - + @property def tool_names(self) -> list[str]: """Get list of registered tool names.""" return list(self._tools.keys()) - + def __len__(self) -> int: return len(self._tools) - + def __contains__(self, name: str) -> bool: return name in self._tools diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index 6b5787402..ce1992092 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -11,7 +11,7 @@ from nanobot.agent.tools.base import Tool class ExecTool(Tool): """Tool to execute shell commands.""" - + def __init__( self, timeout: int = 60, @@ -37,15 +37,15 @@ class ExecTool(Tool): self.allow_patterns = allow_patterns or [] self.restrict_to_workspace = restrict_to_workspace self.path_append = path_append - + @property def name(self) -> str: return "exec" - + @property def description(self) -> str: return "Execute a shell command and return its output. Use with caution." - + @property def parameters(self) -> dict[str, Any]: return { diff --git a/nanobot/agent/tools/spawn.py b/nanobot/agent/tools/spawn.py index fb816ca3e..fc62bf8df 100644 --- a/nanobot/agent/tools/spawn.py +++ b/nanobot/agent/tools/spawn.py @@ -1,6 +1,6 @@ """Spawn tool for creating background subagents.""" -from typing import Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Any from nanobot.agent.tools.base import Tool @@ -10,23 +10,23 @@ if TYPE_CHECKING: class SpawnTool(Tool): """Tool to spawn a subagent for background task execution.""" - + def __init__(self, manager: "SubagentManager"): self._manager = manager self._origin_channel = "cli" self._origin_chat_id = "direct" self._session_key = "cli:direct" - + def set_context(self, channel: str, chat_id: str) -> None: """Set the origin context for subagent announcements.""" self._origin_channel = channel self._origin_chat_id = chat_id self._session_key = f"{channel}:{chat_id}" - + @property def name(self) -> str: return "spawn" - + @property def description(self) -> str: return ( @@ -34,7 +34,7 @@ class SpawnTool(Tool): "Use this for complex or time-consuming tasks that can run independently. " "The subagent will complete the task and report back when done." ) - + @property def parameters(self) -> dict[str, Any]: return { @@ -51,7 +51,7 @@ class SpawnTool(Tool): }, "required": ["task"], } - + async def execute(self, task: str, label: str | None = None, **kwargs: Any) -> str: """Spawn a subagent to execute the given task.""" return await self._manager.spawn( diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index 7860f12ea..e817a4cea 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -45,7 +45,7 @@ def _validate_url(url: str) -> tuple[bool, str]: class WebSearchTool(Tool): """Search the web using Brave Search API.""" - + name = "web_search" description = "Search the web. Returns titles, URLs, and snippets." parameters = { @@ -56,7 +56,7 @@ class WebSearchTool(Tool): }, "required": ["query"] } - + def __init__(self, api_key: str | None = None, max_results: int = 5): self._init_api_key = api_key self.max_results = max_results @@ -73,7 +73,7 @@ class WebSearchTool(Tool): "Set it in ~/.nanobot/config.json under tools.web.search.apiKey " "(or export BRAVE_API_KEY), then restart the gateway." ) - + try: n = min(max(count or self.max_results, 1), 10) async with httpx.AsyncClient() as client: @@ -84,11 +84,11 @@ class WebSearchTool(Tool): timeout=10.0 ) r.raise_for_status() - + results = r.json().get("web", {}).get("results", []) if not results: return f"No results for: {query}" - + lines = [f"Results for: {query}\n"] for i, item in enumerate(results[:n], 1): lines.append(f"{i}. {item.get('title', '')}\n {item.get('url', '')}") @@ -101,7 +101,7 @@ class WebSearchTool(Tool): class WebFetchTool(Tool): """Fetch and extract content from a URL using Readability.""" - + name = "web_fetch" description = "Fetch URL and extract readable content (HTML โ†’ markdown/text)." parameters = { @@ -113,10 +113,10 @@ class WebFetchTool(Tool): }, "required": ["url"] } - + def __init__(self, max_chars: int = 50000): self.max_chars = max_chars - + async def execute(self, url: str, extractMode: str = "markdown", maxChars: int | None = None, **kwargs: Any) -> str: from readability import Document @@ -135,9 +135,9 @@ class WebFetchTool(Tool): ) as client: r = await client.get(url, headers={"User-Agent": USER_AGENT}) r.raise_for_status() - + ctype = r.headers.get("content-type", "") - + # JSON if "application/json" in ctype: text, extractor = json.dumps(r.json(), indent=2, ensure_ascii=False), "json" @@ -149,16 +149,16 @@ class WebFetchTool(Tool): extractor = "readability" else: text, extractor = r.text, "raw" - + truncated = len(text) > max_chars if truncated: text = text[:max_chars] - + return json.dumps({"url": url, "finalUrl": str(r.url), "status": r.status_code, "extractor": extractor, "truncated": truncated, "length": len(text), "text": text}, ensure_ascii=False) except Exception as e: return json.dumps({"error": str(e), "url": url}, ensure_ascii=False) - + def _to_markdown(self, html: str) -> str: """Convert HTML to markdown.""" # Convert links, headings, lists before stripping tags diff --git a/nanobot/bus/events.py b/nanobot/bus/events.py index a48660d34..018c25b3d 100644 --- a/nanobot/bus/events.py +++ b/nanobot/bus/events.py @@ -8,7 +8,7 @@ from typing import Any @dataclass class InboundMessage: """Message received from a chat channel.""" - + channel: str # telegram, discord, slack, whatsapp sender_id: str # User identifier chat_id: str # Chat/channel identifier @@ -17,7 +17,7 @@ class InboundMessage: media: list[str] = field(default_factory=list) # Media URLs metadata: dict[str, Any] = field(default_factory=dict) # Channel-specific data session_key_override: str | None = None # Optional override for thread-scoped sessions - + @property def session_key(self) -> str: """Unique key for session identification.""" @@ -27,7 +27,7 @@ class InboundMessage: @dataclass class OutboundMessage: """Message to send to a chat channel.""" - + channel: str chat_id: str content: str diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index 30103739d..f7959312d 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -12,17 +12,17 @@ from nanobot.bus.queue import MessageBus class BaseChannel(ABC): """ Abstract base class for chat channel implementations. - + Each channel (Telegram, Discord, etc.) should implement this interface to integrate with the nanobot message bus. """ - + name: str = "base" - + def __init__(self, config: Any, bus: MessageBus): """ Initialize the channel. - + Args: config: Channel-specific configuration. bus: The message bus for communication. @@ -30,50 +30,50 @@ class BaseChannel(ABC): self.config = config self.bus = bus self._running = False - + @abstractmethod async def start(self) -> None: """ Start the channel and begin listening for messages. - + This should be a long-running async task that: 1. Connects to the chat platform 2. Listens for incoming messages 3. Forwards messages to the bus via _handle_message() """ pass - + @abstractmethod async def stop(self) -> None: """Stop the channel and clean up resources.""" pass - + @abstractmethod async def send(self, msg: OutboundMessage) -> None: """ Send a message through this channel. - + Args: msg: The message to send. """ pass - + def is_allowed(self, sender_id: str) -> bool: """ Check if a sender is allowed to use this bot. - + Args: sender_id: The sender's identifier. - + Returns: True if allowed, False otherwise. """ allow_list = getattr(self.config, "allow_from", []) - + # If no allow list, allow everyone if not allow_list: return True - + sender_str = str(sender_id) if sender_str in allow_list: return True @@ -82,7 +82,7 @@ class BaseChannel(ABC): if part and part in allow_list: return True return False - + async def _handle_message( self, sender_id: str, @@ -94,9 +94,9 @@ class BaseChannel(ABC): ) -> None: """ Handle an incoming message from the chat platform. - + This method checks permissions and forwards to the bus. - + Args: sender_id: The sender's identifier. chat_id: The chat/channel identifier. @@ -112,7 +112,7 @@ class BaseChannel(ABC): sender_id, self.name, ) return - + msg = InboundMessage( channel=self.name, sender_id=str(sender_id), @@ -122,9 +122,9 @@ class BaseChannel(ABC): metadata=metadata or {}, session_key_override=session_key, ) - + await self.bus.publish_inbound(msg) - + @property def is_running(self) -> bool: """Check if the channel is running.""" diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py index 09c771495..371c45b0b 100644 --- a/nanobot/channels/dingtalk.py +++ b/nanobot/channels/dingtalk.py @@ -5,8 +5,8 @@ import json import time from typing import Any -from loguru import logger import httpx +from loguru import logger from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus @@ -15,11 +15,11 @@ from nanobot.config.schema import DingTalkConfig try: from dingtalk_stream import ( - DingTalkStreamClient, - Credential, + AckMessage, CallbackHandler, CallbackMessage, - AckMessage, + Credential, + DingTalkStreamClient, ) from dingtalk_stream.chatbot import ChatbotMessage diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index b9227fb99..57e59220c 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -14,7 +14,6 @@ from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel 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 diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 6703f2124..9911d08d2 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -23,12 +23,11 @@ try: CreateFileRequestBody, CreateImageRequest, CreateImageRequestBody, - CreateMessageRequest, - CreateMessageRequestBody, CreateMessageReactionRequest, CreateMessageReactionRequestBody, + CreateMessageRequest, + CreateMessageRequestBody, Emoji, - GetFileRequest, GetMessageResourceRequest, P2ImMessageReceiveV1, ) @@ -70,7 +69,7 @@ def _extract_share_card_content(content_json: dict, msg_type: str) -> str: def _extract_interactive_content(content: dict) -> list[str]: """Recursively extract text and links from interactive card content.""" parts = [] - + if isinstance(content, str): try: content = json.loads(content) @@ -104,19 +103,19 @@ def _extract_interactive_content(content: dict) -> list[str]: header_text = header_title.get("content", "") or header_title.get("text", "") if header_text: parts.append(f"title: {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", "") - + if tag in ("markdown", "lark_md"): content = element.get("content", "") if content: @@ -177,17 +176,17 @@ def _extract_element_content(element: dict) -> list[str]: else: for ne in element.get("elements", []): parts.extend(_extract_element_content(ne)) - + return parts def _extract_post_content(content_json: dict) -> tuple[str, list[str]]: """Extract text and image keys from Feishu post (rich text) message content. - + Supports two formats: 1. Direct format: {"title": "...", "content": [...]} 2. Localized format: {"zh_cn": {"title": "...", "content": [...]}} - + Returns: (text, image_keys) - extracted text and list of image keys """ @@ -220,26 +219,26 @@ def _extract_post_content(content_json: dict) -> tuple[str, list[str]]: image_keys.append(img_key) text = " ".join(text_parts).strip() if text_parts else None return text, image_keys - + # Try direct format first if "content" in content_json: text, images = extract_from_lang(content_json) if text or images: return text or "", images - + # Try localized format for lang_key in ("zh_cn", "en_us", "ja_jp"): lang_content = content_json.get(lang_key) text, images = extract_from_lang(lang_content) if text or images: return text or "", images - + return "", [] def _extract_post_text(content_json: dict) -> str: """Extract plain text from Feishu post (rich text) message content. - + Legacy wrapper for _extract_post_content, returns only text. """ text, _ = _extract_post_content(content_json) @@ -249,17 +248,17 @@ def _extract_post_text(content_json: dict) -> str: class FeishuChannel(BaseChannel): """ Feishu/Lark channel using WebSocket long connection. - + Uses WebSocket to receive events - no public IP or webhook required. - + Requires: - App ID and App Secret from Feishu Open Platform - Bot capability enabled - Event subscription enabled (im.message.receive_v1) """ - + name = "feishu" - + def __init__(self, config: FeishuConfig, bus: MessageBus): super().__init__(config, bus) self.config: FeishuConfig = config @@ -268,27 +267,27 @@ class FeishuChannel(BaseChannel): self._ws_thread: threading.Thread | None = None self._processed_message_ids: OrderedDict[str, None] = OrderedDict() # Ordered dedup cache self._loop: asyncio.AbstractEventLoop | None = None - + async def start(self) -> None: """Start the Feishu bot with WebSocket long connection.""" if not FEISHU_AVAILABLE: logger.error("Feishu SDK not installed. Run: pip install lark-oapi") return - + if not self.config.app_id or not self.config.app_secret: logger.error("Feishu app_id and app_secret not configured") return - + self._running = True self._loop = asyncio.get_running_loop() - + # Create Lark client for sending messages self._client = lark.Client.builder() \ .app_id(self.config.app_id) \ .app_secret(self.config.app_secret) \ .log_level(lark.LogLevel.INFO) \ .build() - + # Create event handler (only register message receive, ignore other events) event_handler = lark.EventDispatcherHandler.builder( self.config.encrypt_key or "", @@ -296,7 +295,7 @@ class FeishuChannel(BaseChannel): ).register_p2_im_message_receive_v1( self._on_message_sync ).build() - + # Create WebSocket client for long connection self._ws_client = lark.ws.Client( self.config.app_id, @@ -304,7 +303,7 @@ class FeishuChannel(BaseChannel): event_handler=event_handler, log_level=lark.LogLevel.INFO ) - + # Start WebSocket client in a separate thread with reconnect loop def run_ws(): while self._running: @@ -313,18 +312,19 @@ class FeishuChannel(BaseChannel): except Exception as e: logger.warning("Feishu WebSocket error: {}", e) if self._running: - import time; time.sleep(5) - + import time + time.sleep(5) + self._ws_thread = threading.Thread(target=run_ws, daemon=True) self._ws_thread.start() - + logger.info("Feishu bot started with WebSocket long connection") logger.info("No public IP required - using WebSocket to receive events") - + # Keep running until stopped while self._running: await asyncio.sleep(1) - + async def stop(self) -> None: """Stop the Feishu bot.""" self._running = False @@ -334,7 +334,7 @@ class FeishuChannel(BaseChannel): except Exception as e: logger.warning("Error stopping WebSocket client: {}", e) logger.info("Feishu bot stopped") - + def _add_reaction_sync(self, message_id: str, emoji_type: str) -> None: """Sync helper for adding reaction (runs in thread pool).""" try: @@ -345,9 +345,9 @@ class FeishuChannel(BaseChannel): .reaction_type(Emoji.builder().emoji_type(emoji_type).build()) .build() ).build() - + response = self._client.im.v1.message_reaction.create(request) - + if not response.success(): logger.warning("Failed to add reaction: code={}, msg={}", response.code, response.msg) else: @@ -358,15 +358,15 @@ class FeishuChannel(BaseChannel): async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> None: """ Add a reaction emoji to a message (non-blocking). - + Common emoji types: THUMBSUP, OK, EYES, DONE, OnIt, HEART """ if not self._client or not Emoji: return - + loop = asyncio.get_running_loop() await loop.run_in_executor(None, self._add_reaction_sync, message_id, emoji_type) - + # Regex to match markdown tables (header + separator + data rows) _TABLE_RE = re.compile( r"((?:^[ \t]*\|.+\|[ \t]*\n)(?:^[ \t]*\|[-:\s|]+\|[ \t]*\n)(?:^[ \t]*\|.+\|[ \t]*\n?)+)", @@ -380,12 +380,13 @@ class FeishuChannel(BaseChannel): @staticmethod def _parse_md_table(table_text: str) -> dict | None: """Parse a markdown table into a Feishu table element.""" - lines = [l.strip() for l in table_text.strip().split("\n") if l.strip()] + lines = [_line.strip() for _line in table_text.strip().split("\n") if _line.strip()] if len(lines) < 3: return None - split = lambda l: [c.strip() for c in l.strip("|").split("|")] + def split(_line: str) -> list[str]: + return [c.strip() for c in _line.strip("|").split("|")] headers = split(lines[0]) - rows = [split(l) for l in lines[2:]] + rows = [split(_line) for _line in lines[2:]] columns = [{"tag": "column", "name": f"c{i}", "display_name": h, "width": "auto"} for i, h in enumerate(headers)] return { @@ -657,7 +658,7 @@ class FeishuChannel(BaseChannel): except Exception as e: logger.error("Error sending Feishu message: {}", e) - + def _on_message_sync(self, data: "P2ImMessageReceiveV1") -> None: """ Sync handler for incoming messages (called from WebSocket thread). @@ -665,7 +666,7 @@ class FeishuChannel(BaseChannel): """ if self._loop and self._loop.is_running(): asyncio.run_coroutine_threadsafe(self._on_message(data), self._loop) - + async def _on_message(self, data: "P2ImMessageReceiveV1") -> None: """Handle incoming message from Feishu.""" try: diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index c8df6b2a6..4b40d0ee5 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -16,24 +16,24 @@ from nanobot.config.schema import Config class ChannelManager: """ Manages chat channels and coordinates message routing. - + Responsibilities: - Initialize enabled channels (Telegram, WhatsApp, etc.) - Start/stop channels - Route outbound messages """ - + def __init__(self, config: Config, bus: MessageBus): self.config = config self.bus = bus self.channels: dict[str, BaseChannel] = {} self._dispatch_task: asyncio.Task | None = None - + self._init_channels() - + def _init_channels(self) -> None: """Initialize channels based on config.""" - + # Telegram channel if self.config.channels.telegram.enabled: try: @@ -46,7 +46,7 @@ class ChannelManager: logger.info("Telegram channel enabled") except ImportError as e: logger.warning("Telegram channel not available: {}", e) - + # WhatsApp channel if self.config.channels.whatsapp.enabled: try: @@ -68,7 +68,7 @@ class ChannelManager: logger.info("Discord channel enabled") except ImportError as e: logger.warning("Discord channel not available: {}", e) - + # Feishu channel if self.config.channels.feishu.enabled: try: @@ -136,7 +136,7 @@ class ChannelManager: logger.info("QQ channel enabled") except ImportError as e: logger.warning("QQ channel not available: {}", e) - + # Matrix channel if self.config.channels.matrix.enabled: try: @@ -148,7 +148,7 @@ class ChannelManager: logger.info("Matrix channel enabled") except ImportError as e: logger.warning("Matrix channel not available: {}", e) - + async def _start_channel(self, name: str, channel: BaseChannel) -> None: """Start a channel and log any exceptions.""" try: @@ -161,23 +161,23 @@ class ChannelManager: if not self.channels: logger.warning("No channels enabled") return - + # Start outbound dispatcher self._dispatch_task = asyncio.create_task(self._dispatch_outbound()) - + # Start channels tasks = [] for name, channel in self.channels.items(): logger.info("Starting {} channel...", name) tasks.append(asyncio.create_task(self._start_channel(name, channel))) - + # Wait for all to complete (they should run forever) await asyncio.gather(*tasks, return_exceptions=True) - + async def stop_all(self) -> None: """Stop all channels and the dispatcher.""" logger.info("Stopping all channels...") - + # Stop dispatcher if self._dispatch_task: self._dispatch_task.cancel() @@ -185,7 +185,7 @@ class ChannelManager: await self._dispatch_task except asyncio.CancelledError: pass - + # Stop all channels for name, channel in self.channels.items(): try: @@ -193,24 +193,24 @@ class ChannelManager: logger.info("Stopped {} channel", name) except Exception as e: logger.error("Error stopping {}: {}", name, e) - + async def _dispatch_outbound(self) -> None: """Dispatch outbound messages to the appropriate channel.""" logger.info("Outbound dispatcher started") - + while True: try: msg = await asyncio.wait_for( self.bus.consume_outbound(), timeout=1.0 ) - + if msg.metadata.get("_progress"): if msg.metadata.get("_tool_hint") and not self.config.channels.send_tool_hints: continue if not msg.metadata.get("_tool_hint") and not self.config.channels.send_progress: continue - + channel = self.channels.get(msg.channel) if channel: try: @@ -219,16 +219,16 @@ class ChannelManager: logger.error("Error sending to {}: {}", msg.channel, e) else: logger.warning("Unknown channel: {}", msg.channel) - + except asyncio.TimeoutError: continue except asyncio.CancelledError: break - + def get_channel(self, name: str) -> BaseChannel | None: """Get a channel by name.""" return self.channels.get(name) - + def get_status(self) -> dict[str, Any]: """Get status of all channels.""" return { @@ -238,7 +238,7 @@ class ChannelManager: } for name, channel in self.channels.items() } - + @property def enabled_channels(self) -> list[str]: """Get list of enabled channel names.""" diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 21192e94b..43fc57317 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -12,10 +12,22 @@ try: import nh3 from mistune import create_markdown from nio import ( - AsyncClient, AsyncClientConfig, ContentRepositoryConfigError, - DownloadError, InviteEvent, JoinError, MatrixRoom, MemoryDownloadResponse, - RoomEncryptedMedia, RoomMessage, RoomMessageMedia, RoomMessageText, - RoomSendError, RoomTypingError, SyncError, UploadError, + AsyncClient, + AsyncClientConfig, + ContentRepositoryConfigError, + DownloadError, + InviteEvent, + JoinError, + MatrixRoom, + MemoryDownloadResponse, + RoomEncryptedMedia, + RoomMessage, + RoomMessageMedia, + RoomMessageText, + RoomSendError, + RoomTypingError, + SyncError, + UploadError, ) from nio.crypto.attachments import decrypt_attachment from nio.exceptions import EncryptionError diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index 57bfbcbef..afd1d2dcd 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -5,11 +5,10 @@ import re from typing import Any from loguru import logger -from slack_sdk.socket_mode.websockets import SocketModeClient from slack_sdk.socket_mode.request import SocketModeRequest from slack_sdk.socket_mode.response import SocketModeResponse +from slack_sdk.socket_mode.websockets import SocketModeClient from slack_sdk.web.async_client import AsyncWebClient - from slackify_markdown import slackify_markdown from nanobot.bus.events import OutboundMessage diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 969d853a9..c290535fd 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -4,9 +4,10 @@ from __future__ import annotations import asyncio import re + from loguru import logger -from telegram import BotCommand, Update, ReplyParameters -from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes +from telegram import BotCommand, ReplyParameters, Update +from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters from telegram.request import HTTPXRequest from nanobot.bus.events import OutboundMessage @@ -21,60 +22,60 @@ def _markdown_to_telegram_html(text: str) -> str: """ if not text: return "" - + # 1. Extract and protect code blocks (preserve content from other processing) code_blocks: list[str] = [] def save_code_block(m: re.Match) -> str: code_blocks.append(m.group(1)) return f"\x00CB{len(code_blocks) - 1}\x00" - + text = re.sub(r'```[\w]*\n?([\s\S]*?)```', save_code_block, text) - + # 2. Extract and protect inline code inline_codes: list[str] = [] def save_inline_code(m: re.Match) -> str: inline_codes.append(m.group(1)) return f"\x00IC{len(inline_codes) - 1}\x00" - + text = re.sub(r'`([^`]+)`', save_inline_code, text) - + # 3. Headers # Title -> just the title text text = re.sub(r'^#{1,6}\s+(.+)$', r'\1', text, flags=re.MULTILINE) - + # 4. Blockquotes > text -> just the text (before HTML escaping) text = re.sub(r'^>\s*(.*)$', r'\1', text, flags=re.MULTILINE) - + # 5. Escape HTML special characters text = text.replace("&", "&").replace("<", "<").replace(">", ">") - + # 6. Links [text](url) - must be before bold/italic to handle nested cases text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'\1', text) - + # 7. Bold **text** or __text__ text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) text = re.sub(r'__(.+?)__', r'\1', text) - + # 8. Italic _text_ (avoid matching inside words like some_var_name) text = re.sub(r'(?\1', text) - + # 9. Strikethrough ~~text~~ text = re.sub(r'~~(.+?)~~', r'\1', text) - + # 10. Bullet lists - item -> โ€ข item text = re.sub(r'^[-*]\s+', 'โ€ข ', text, flags=re.MULTILINE) - + # 11. Restore inline code with HTML tags for i, code in enumerate(inline_codes): # Escape HTML in code content escaped = code.replace("&", "&").replace("<", "<").replace(">", ">") text = text.replace(f"\x00IC{i}\x00", f"{escaped}") - + # 12. Restore code blocks with HTML tags for i, code in enumerate(code_blocks): # Escape HTML in code content escaped = code.replace("&", "&").replace("<", "<").replace(">", ">") text = text.replace(f"\x00CB{i}\x00", f"
{escaped}
") - + return text @@ -101,12 +102,12 @@ def _split_message(content: str, max_len: int = 4000) -> list[str]: class TelegramChannel(BaseChannel): """ Telegram channel using long polling. - + Simple and reliable - no webhook/public IP needed. """ - + name = "telegram" - + # Commands registered with Telegram's command menu BOT_COMMANDS = [ BotCommand("start", "Start the bot"), @@ -114,7 +115,7 @@ class TelegramChannel(BaseChannel): BotCommand("stop", "Stop the current task"), BotCommand("help", "Show available commands"), ] - + def __init__( self, config: TelegramConfig, @@ -129,15 +130,15 @@ class TelegramChannel(BaseChannel): self._typing_tasks: dict[str, asyncio.Task] = {} # chat_id -> typing loop task self._media_group_buffers: dict[str, dict] = {} self._media_group_tasks: dict[str, asyncio.Task] = {} - + async def start(self) -> None: """Start the Telegram bot with long polling.""" if not self.config.token: logger.error("Telegram bot token not configured") return - + self._running = True - + # Build the application with larger connection pool to avoid pool-timeout on long runs req = HTTPXRequest(connection_pool_size=16, pool_timeout=5.0, connect_timeout=30.0, read_timeout=30.0) builder = Application.builder().token(self.config.token).request(req).get_updates_request(req) @@ -145,51 +146,51 @@ class TelegramChannel(BaseChannel): builder = builder.proxy(self.config.proxy).get_updates_proxy(self.config.proxy) self._app = builder.build() self._app.add_error_handler(self._on_error) - + # Add command handlers self._app.add_handler(CommandHandler("start", self._on_start)) self._app.add_handler(CommandHandler("new", self._forward_command)) self._app.add_handler(CommandHandler("help", self._on_help)) - + # Add message handler for text, photos, voice, documents self._app.add_handler( MessageHandler( - (filters.TEXT | filters.PHOTO | filters.VOICE | filters.AUDIO | filters.Document.ALL) - & ~filters.COMMAND, + (filters.TEXT | filters.PHOTO | filters.VOICE | filters.AUDIO | filters.Document.ALL) + & ~filters.COMMAND, self._on_message ) ) - + logger.info("Starting Telegram bot (polling mode)...") - + # Initialize and start polling await self._app.initialize() await self._app.start() - + # Get bot info and register command menu bot_info = await self._app.bot.get_me() logger.info("Telegram bot @{} connected", bot_info.username) - + try: await self._app.bot.set_my_commands(self.BOT_COMMANDS) logger.debug("Telegram bot commands registered") except Exception as e: logger.warning("Failed to register bot commands: {}", e) - + # Start polling (this runs until stopped) await self._app.updater.start_polling( allowed_updates=["message"], drop_pending_updates=True # Ignore old messages on startup ) - + # Keep running until stopped while self._running: await asyncio.sleep(1) - + async def stop(self) -> None: """Stop the Telegram bot.""" self._running = False - + # Cancel all typing indicators for chat_id in list(self._typing_tasks): self._stop_typing(chat_id) @@ -198,14 +199,14 @@ class TelegramChannel(BaseChannel): task.cancel() self._media_group_tasks.clear() self._media_group_buffers.clear() - + if self._app: logger.info("Stopping Telegram bot...") await self._app.updater.stop() await self._app.stop() await self._app.shutdown() self._app = None - + @staticmethod def _get_media_type(path: str) -> str: """Guess media type from file extension.""" @@ -253,7 +254,7 @@ class TelegramChannel(BaseChannel): param = "photo" if media_type == "photo" else media_type if media_type in ("voice", "audio") else "document" with open(media_path, 'rb') as f: await sender( - chat_id=chat_id, + chat_id=chat_id, **{param: f}, reply_parameters=reply_params ) @@ -272,8 +273,8 @@ class TelegramChannel(BaseChannel): try: html = _markdown_to_telegram_html(chunk) await self._app.bot.send_message( - chat_id=chat_id, - text=html, + chat_id=chat_id, + text=html, parse_mode="HTML", reply_parameters=reply_params ) @@ -281,13 +282,13 @@ class TelegramChannel(BaseChannel): logger.warning("HTML parse failed, falling back to plain text: {}", e) try: await self._app.bot.send_message( - chat_id=chat_id, + chat_id=chat_id, text=chunk, reply_parameters=reply_params ) except Exception as e2: logger.error("Error sending Telegram message: {}", e2) - + async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle /start command.""" if not update.message or not update.effective_user: @@ -326,34 +327,34 @@ class TelegramChannel(BaseChannel): chat_id=str(update.message.chat_id), content=update.message.text, ) - + async def _on_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle incoming messages (text, photos, voice, documents).""" if not update.message or not update.effective_user: return - + message = update.message user = update.effective_user chat_id = message.chat_id sender_id = self._sender_id(user) - + # Store chat_id for replies self._chat_ids[sender_id] = chat_id - + # Build content from text and/or media content_parts = [] media_paths = [] - + # Text content if message.text: content_parts.append(message.text) if message.caption: content_parts.append(message.caption) - + # Handle media files media_file = None media_type = None - + if message.photo: media_file = message.photo[-1] # Largest photo media_type = "image" @@ -366,23 +367,23 @@ class TelegramChannel(BaseChannel): elif message.document: media_file = message.document media_type = "file" - + # Download media if present if media_file and self._app: try: file = await self._app.bot.get_file(media_file.file_id) ext = self._get_extension(media_type, getattr(media_file, 'mime_type', None)) - + # Save to workspace/media/ from pathlib import Path media_dir = Path.home() / ".nanobot" / "media" media_dir.mkdir(parents=True, exist_ok=True) - + file_path = media_dir / f"{media_file.file_id[:16]}{ext}" await file.download_to_drive(str(file_path)) - + media_paths.append(str(file_path)) - + # Handle voice transcription if media_type == "voice" or media_type == "audio": from nanobot.providers.transcription import GroqTranscriptionProvider @@ -395,16 +396,16 @@ class TelegramChannel(BaseChannel): content_parts.append(f"[{media_type}: {file_path}]") else: content_parts.append(f"[{media_type}: {file_path}]") - + logger.debug("Downloaded {} to {}", media_type, file_path) except Exception as e: logger.error("Failed to download media: {}", e) content_parts.append(f"[{media_type}: download failed]") - + content = "\n".join(content_parts) if content_parts else "[empty message]" - + logger.debug("Telegram message from {}: {}...", sender_id, content[:50]) - + str_chat_id = str(chat_id) # Telegram media groups: buffer briefly, forward as one aggregated turn. @@ -428,10 +429,10 @@ class TelegramChannel(BaseChannel): if key not in self._media_group_tasks: self._media_group_tasks[key] = asyncio.create_task(self._flush_media_group(key)) return - + # Start typing indicator before processing self._start_typing(str_chat_id) - + # Forward to the message bus await self._handle_message( sender_id=sender_id, @@ -446,7 +447,7 @@ class TelegramChannel(BaseChannel): "is_group": message.chat.type != "private" } ) - + async def _flush_media_group(self, key: str) -> None: """Wait briefly, then forward buffered media-group as one turn.""" try: @@ -467,13 +468,13 @@ class TelegramChannel(BaseChannel): # Cancel any existing typing task for this chat self._stop_typing(chat_id) self._typing_tasks[chat_id] = asyncio.create_task(self._typing_loop(chat_id)) - + def _stop_typing(self, chat_id: str) -> None: """Stop the typing indicator for a chat.""" task = self._typing_tasks.pop(chat_id, None) if task and not task.done(): task.cancel() - + async def _typing_loop(self, chat_id: str) -> None: """Repeatedly send 'typing' action until cancelled.""" try: @@ -484,7 +485,7 @@ class TelegramChannel(BaseChannel): pass except Exception as e: logger.debug("Typing indicator stopped for {}: {}", chat_id, e) - + async def _on_error(self, update: object, context: ContextTypes.DEFAULT_TYPE) -> None: """Log polling / handler errors instead of silently swallowing them.""" logger.error("Telegram error: {}", context.error) @@ -498,6 +499,6 @@ class TelegramChannel(BaseChannel): } if mime_type in ext_map: return ext_map[mime_type] - + type_map = {"image": ".jpg", "voice": ".ogg", "audio": ".mp3", "file": ""} return type_map.get(media_type, "") diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index 49d239065..0d1ec7eea 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -3,7 +3,6 @@ import asyncio import json from collections import OrderedDict -from typing import Any from loguru import logger @@ -29,17 +28,17 @@ class WhatsAppChannel(BaseChannel): self._ws = None self._connected = False self._processed_message_ids: OrderedDict[str, None] = OrderedDict() - + async def start(self) -> None: """Start the WhatsApp channel by connecting to the bridge.""" import websockets - + bridge_url = self.config.bridge_url - + logger.info("Connecting to WhatsApp bridge at {}...", bridge_url) - + self._running = True - + while self._running: try: async with websockets.connect(bridge_url) as ws: @@ -49,40 +48,40 @@ class WhatsAppChannel(BaseChannel): await ws.send(json.dumps({"type": "auth", "token": self.config.bridge_token})) self._connected = True logger.info("Connected to WhatsApp bridge") - + # Listen for messages async for message in ws: try: await self._handle_bridge_message(message) except Exception as e: logger.error("Error handling bridge message: {}", e) - + except asyncio.CancelledError: break except Exception as e: self._connected = False self._ws = None logger.warning("WhatsApp bridge connection error: {}", e) - + if self._running: logger.info("Reconnecting in 5 seconds...") await asyncio.sleep(5) - + async def stop(self) -> None: """Stop the WhatsApp channel.""" self._running = False self._connected = False - + if self._ws: await self._ws.close() self._ws = None - + async def send(self, msg: OutboundMessage) -> None: """Send a message through WhatsApp.""" if not self._ws or not self._connected: logger.warning("WhatsApp bridge not connected") return - + try: payload = { "type": "send", @@ -92,7 +91,7 @@ class WhatsAppChannel(BaseChannel): await self._ws.send(json.dumps(payload, ensure_ascii=False)) except Exception as e: logger.error("Error sending WhatsApp message: {}", e) - + async def _handle_bridge_message(self, raw: str) -> None: """Handle a message from the bridge.""" try: @@ -100,9 +99,9 @@ class WhatsAppChannel(BaseChannel): except json.JSONDecodeError: logger.warning("Invalid JSON from bridge: {}", raw[:100]) return - + msg_type = data.get("type") - + if msg_type == "message": # Incoming message from WhatsApp # Deprecated by whatsapp: old phone number style typically: @s.whatspp.net @@ -139,20 +138,20 @@ class WhatsAppChannel(BaseChannel): "is_group": data.get("isGroup", False) } ) - + elif msg_type == "status": # Connection status update status = data.get("status") logger.info("WhatsApp status: {}", status) - + if status == "connected": self._connected = True elif status == "disconnected": self._connected = False - + elif msg_type == "qr": # QR code for authentication logger.info("Scan QR code in the bridge terminal to connect WhatsApp") - + elif msg_type == "error": logger.error("WhatsApp bridge error: {}", data.get('error')) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index fc4c261ea..15bee4cfb 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -2,23 +2,22 @@ import asyncio import os -import signal -from pathlib import Path import select +import signal import sys +from pathlib import Path import typer +from prompt_toolkit import PromptSession +from prompt_toolkit.formatted_text import HTML +from prompt_toolkit.history import FileHistory +from prompt_toolkit.patch_stdout import patch_stdout from rich.console import Console from rich.markdown import Markdown from rich.table import Table from rich.text import Text -from prompt_toolkit import PromptSession -from prompt_toolkit.formatted_text import HTML -from prompt_toolkit.history import FileHistory -from prompt_toolkit.patch_stdout import patch_stdout - -from nanobot import __version__, __logo__ +from nanobot import __logo__, __version__ from nanobot.config.schema import Config from nanobot.utils.helpers import sync_workspace_templates @@ -160,9 +159,9 @@ def onboard(): from nanobot.config.loader import get_config_path, load_config, save_config from nanobot.config.schema import Config from nanobot.utils.helpers import get_workspace_path - + config_path = get_config_path() - + if config_path.exists(): console.print(f"[yellow]Config already exists at {config_path}[/yellow]") console.print(" [bold]y[/bold] = overwrite with defaults (existing values will be lost)") @@ -178,16 +177,16 @@ def onboard(): else: save_config(Config()) console.print(f"[green]โœ“[/green] Created config at {config_path}") - + # Create workspace workspace = get_workspace_path() - + if not workspace.exists(): workspace.mkdir(parents=True, exist_ok=True) console.print(f"[green]โœ“[/green] Created workspace at {workspace}") - + sync_workspace_templates(workspace) - + console.print(f"\n{__logo__} nanobot is ready!") console.print("\nNext steps:") console.print(" 1. Add your API key to [cyan]~/.nanobot/config.json[/cyan]") @@ -201,9 +200,9 @@ def onboard(): def _make_provider(config: Config): """Create the appropriate LLM provider from config.""" + from nanobot.providers.custom_provider import CustomProvider from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.openai_codex_provider import OpenAICodexProvider - from nanobot.providers.custom_provider import CustomProvider model = config.agents.defaults.model provider_name = config.get_provider_name(model) @@ -248,31 +247,31 @@ def gateway( verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"), ): """Start the nanobot gateway.""" - from nanobot.config.loader import load_config, get_data_dir - from nanobot.bus.queue import MessageBus from nanobot.agent.loop import AgentLoop + from nanobot.bus.queue import MessageBus from nanobot.channels.manager import ChannelManager - from nanobot.session.manager import SessionManager + from nanobot.config.loader import get_data_dir, load_config from nanobot.cron.service import CronService from nanobot.cron.types import CronJob from nanobot.heartbeat.service import HeartbeatService - + from nanobot.session.manager import SessionManager + if verbose: import logging logging.basicConfig(level=logging.DEBUG) - + console.print(f"{__logo__} Starting nanobot gateway on port {port}...") - + config = load_config() sync_workspace_templates(config.workspace_path) bus = MessageBus() provider = _make_provider(config) session_manager = SessionManager(config.workspace_path) - + # Create cron service first (callback set after agent creation) cron_store_path = get_data_dir() / "cron" / "jobs.json" cron = CronService(cron_store_path) - + # Create agent with cron service agent = AgentLoop( bus=bus, @@ -291,7 +290,7 @@ def gateway( mcp_servers=config.tools.mcp_servers, channels_config=config.channels, ) - + # Set cron callback (needs agent) async def on_cron_job(job: CronJob) -> str | None: """Execute a cron job through the agent.""" @@ -310,7 +309,7 @@ def gateway( )) return response cron.on_job = on_cron_job - + # Create channel manager channels = ChannelManager(config, bus) @@ -364,18 +363,18 @@ def gateway( interval_s=hb_cfg.interval_s, enabled=hb_cfg.enabled, ) - + if channels.enabled_channels: console.print(f"[green]โœ“[/green] Channels enabled: {', '.join(channels.enabled_channels)}") else: console.print("[yellow]Warning: No channels enabled[/yellow]") - + cron_status = cron.status() if cron_status["jobs"] > 0: console.print(f"[green]โœ“[/green] Cron: {cron_status['jobs']} scheduled jobs") - + console.print(f"[green]โœ“[/green] Heartbeat: every {hb_cfg.interval_s}s") - + async def run(): try: await cron.start() @@ -392,7 +391,7 @@ def gateway( cron.stop() agent.stop() await channels.stop_all() - + asyncio.run(run()) @@ -411,15 +410,16 @@ def agent( logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"), ): """Interact with the agent directly.""" - from nanobot.config.loader import load_config, get_data_dir - from nanobot.bus.queue import MessageBus - from nanobot.agent.loop import AgentLoop - from nanobot.cron.service import CronService from loguru import logger - + + from nanobot.agent.loop import AgentLoop + from nanobot.bus.queue import MessageBus + from nanobot.config.loader import get_data_dir, load_config + from nanobot.cron.service import CronService + config = load_config() sync_workspace_templates(config.workspace_path) - + bus = MessageBus() provider = _make_provider(config) @@ -431,7 +431,7 @@ def agent( logger.enable("nanobot") else: logger.disable("nanobot") - + agent_loop = AgentLoop( bus=bus, provider=provider, @@ -448,7 +448,7 @@ def agent( mcp_servers=config.tools.mcp_servers, channels_config=config.channels, ) - + # Show spinner when logs are off (no output to miss); skip when logs are on def _thinking_ctx(): if logs: @@ -624,7 +624,7 @@ def channels_status(): "โœ“" if mc.enabled else "โœ—", mc_base ) - + # Telegram tg = config.channels.telegram tg_config = f"token: {tg.token[:10]}..." if tg.token else "[dim]not configured[/dim]" @@ -677,57 +677,57 @@ def _get_bridge_dir() -> Path: """Get the bridge directory, setting it up if needed.""" import shutil import subprocess - + # User's bridge location user_bridge = Path.home() / ".nanobot" / "bridge" - + # Check if already built if (user_bridge / "dist" / "index.js").exists(): return user_bridge - + # Check for npm if not shutil.which("npm"): console.print("[red]npm not found. Please install Node.js >= 18.[/red]") raise typer.Exit(1) - + # Find source bridge: first check package data, then source dir pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed) src_bridge = Path(__file__).parent.parent.parent / "bridge" # repo root/bridge (dev) - + source = None if (pkg_bridge / "package.json").exists(): source = pkg_bridge elif (src_bridge / "package.json").exists(): source = src_bridge - + if not source: console.print("[red]Bridge source not found.[/red]") console.print("Try reinstalling: pip install --force-reinstall nanobot") raise typer.Exit(1) - + console.print(f"{__logo__} Setting up bridge...") - + # Copy to user directory user_bridge.parent.mkdir(parents=True, exist_ok=True) if user_bridge.exists(): shutil.rmtree(user_bridge) shutil.copytree(source, user_bridge, ignore=shutil.ignore_patterns("node_modules", "dist")) - + # Install and build try: console.print(" Installing dependencies...") subprocess.run(["npm", "install"], cwd=user_bridge, check=True, capture_output=True) - + console.print(" Building...") subprocess.run(["npm", "run", "build"], cwd=user_bridge, check=True, capture_output=True) - + console.print("[green]โœ“[/green] Bridge ready\n") except subprocess.CalledProcessError as e: console.print(f"[red]Build failed: {e}[/red]") if e.stderr: console.print(f"[dim]{e.stderr.decode()[:500]}[/dim]") raise typer.Exit(1) - + return user_bridge @@ -735,18 +735,19 @@ def _get_bridge_dir() -> Path: def channels_login(): """Link device via QR code.""" import subprocess + from nanobot.config.loader import load_config - + config = load_config() bridge_dir = _get_bridge_dir() - + console.print(f"{__logo__} Starting bridge...") console.print("Scan the QR code to connect.\n") - + env = {**os.environ} if config.channels.whatsapp.bridge_token: env["BRIDGE_TOKEN"] = config.channels.whatsapp.bridge_token - + try: subprocess.run(["npm", "start"], cwd=bridge_dir, check=True, env=env) except subprocess.CalledProcessError as e: @@ -770,23 +771,23 @@ def cron_list( """List scheduled jobs.""" from nanobot.config.loader import get_data_dir from nanobot.cron.service import CronService - + store_path = get_data_dir() / "cron" / "jobs.json" service = CronService(store_path) - + jobs = service.list_jobs(include_disabled=all) - + if not jobs: console.print("No scheduled jobs.") return - + table = Table(title="Scheduled Jobs") table.add_column("ID", style="cyan") table.add_column("Name") table.add_column("Schedule") table.add_column("Status") table.add_column("Next Run") - + import time from datetime import datetime as _dt from zoneinfo import ZoneInfo @@ -798,7 +799,7 @@ def cron_list( sched = f"{job.schedule.expr or ''} ({job.schedule.tz})" if job.schedule.tz else (job.schedule.expr or "") else: sched = "one-time" - + # Format next run next_run = "" if job.state.next_run_at_ms: @@ -808,11 +809,11 @@ def cron_list( next_run = _dt.fromtimestamp(ts, tz).strftime("%Y-%m-%d %H:%M") except Exception: next_run = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) - + status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]" - + table.add_row(job.id, job.name, sched, status, next_run) - + console.print(table) @@ -832,7 +833,7 @@ def cron_add( from nanobot.config.loader import get_data_dir from nanobot.cron.service import CronService from nanobot.cron.types import CronSchedule - + if tz and not cron_expr: console.print("[red]Error: --tz can only be used with --cron[/red]") raise typer.Exit(1) @@ -849,10 +850,10 @@ def cron_add( else: console.print("[red]Error: Must specify --every, --cron, or --at[/red]") raise typer.Exit(1) - + store_path = get_data_dir() / "cron" / "jobs.json" service = CronService(store_path) - + try: job = service.add_job( name=name, @@ -876,10 +877,10 @@ def cron_remove( """Remove a scheduled job.""" from nanobot.config.loader import get_data_dir from nanobot.cron.service import CronService - + store_path = get_data_dir() / "cron" / "jobs.json" service = CronService(store_path) - + if service.remove_job(job_id): console.print(f"[green]โœ“[/green] Removed job {job_id}") else: @@ -894,10 +895,10 @@ def cron_enable( """Enable or disable a job.""" from nanobot.config.loader import get_data_dir from nanobot.cron.service import CronService - + store_path = get_data_dir() / "cron" / "jobs.json" service = CronService(store_path) - + job = service.enable_job(job_id, enabled=not disable) if job: status = "disabled" if disable else "enabled" @@ -913,11 +914,12 @@ def cron_run( ): """Manually run a job.""" from loguru import logger - from nanobot.config.loader import load_config, get_data_dir + + from nanobot.agent.loop import AgentLoop + from nanobot.bus.queue import MessageBus + from nanobot.config.loader import get_data_dir, load_config from nanobot.cron.service import CronService from nanobot.cron.types import CronJob - from nanobot.bus.queue import MessageBus - from nanobot.agent.loop import AgentLoop logger.disable("nanobot") config = load_config() @@ -975,7 +977,7 @@ def cron_run( @app.command() def status(): """Show nanobot status.""" - from nanobot.config.loader import load_config, get_config_path + from nanobot.config.loader import get_config_path, load_config config_path = get_config_path() config = load_config() @@ -990,7 +992,7 @@ def status(): from nanobot.providers.registry import PROVIDERS console.print(f"Model: {config.agents.defaults.model}") - + # Check API keys from registry for spec in PROVIDERS: p = getattr(config.providers, spec.name, None) diff --git a/nanobot/config/__init__.py b/nanobot/config/__init__.py index 88e8e9b07..6c5966859 100644 --- a/nanobot/config/__init__.py +++ b/nanobot/config/__init__.py @@ -1,6 +1,6 @@ """Configuration module for nanobot.""" -from nanobot.config.loader import load_config, get_config_path +from nanobot.config.loader import get_config_path, load_config from nanobot.config.schema import Config __all__ = ["Config", "load_config", "get_config_path"] diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 1ff9782f2..a908f3d54 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Literal -from pydantic import BaseModel, Field, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel from pydantic_settings import BaseSettings diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index 6889a1060..cc3b7b2a9 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -21,17 +21,18 @@ def _compute_next_run(schedule: CronSchedule, now_ms: int) -> int | None: """Compute next run time in ms.""" if schedule.kind == "at": return schedule.at_ms if schedule.at_ms and schedule.at_ms > now_ms else None - + if schedule.kind == "every": if not schedule.every_ms or schedule.every_ms <= 0: return None # Next interval from now return now_ms + schedule.every_ms - + if schedule.kind == "cron" and schedule.expr: try: - from croniter import croniter from zoneinfo import ZoneInfo + + from croniter import croniter # Use caller-provided reference time for deterministic scheduling base_time = now_ms / 1000 tz = ZoneInfo(schedule.tz) if schedule.tz else datetime.now().astimezone().tzinfo @@ -41,7 +42,7 @@ def _compute_next_run(schedule: CronSchedule, now_ms: int) -> int | None: return int(next_dt.timestamp() * 1000) except Exception: return None - + return None @@ -61,7 +62,7 @@ def _validate_schedule_for_add(schedule: CronSchedule) -> None: class CronService: """Service for managing and executing scheduled jobs.""" - + def __init__( self, store_path: Path, @@ -72,12 +73,12 @@ class CronService: self._store: CronStore | None = None self._timer_task: asyncio.Task | None = None self._running = False - + def _load_store(self) -> CronStore: """Load jobs from disk.""" if self._store: return self._store - + if self.store_path.exists(): try: data = json.loads(self.store_path.read_text(encoding="utf-8")) @@ -117,16 +118,16 @@ class CronService: self._store = CronStore() else: self._store = CronStore() - + return self._store - + def _save_store(self) -> None: """Save jobs to disk.""" if not self._store: return - + self.store_path.parent.mkdir(parents=True, exist_ok=True) - + data = { "version": self._store.version, "jobs": [ @@ -161,9 +162,9 @@ class CronService: for j in self._store.jobs ] } - + self.store_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") - + async def start(self) -> None: """Start the cron service.""" self._running = True @@ -172,14 +173,14 @@ class CronService: self._save_store() self._arm_timer() logger.info("Cron service started with {} jobs", len(self._store.jobs if self._store else [])) - + def stop(self) -> None: """Stop the cron service.""" self._running = False if self._timer_task: self._timer_task.cancel() self._timer_task = None - + def _recompute_next_runs(self) -> None: """Recompute next run times for all enabled jobs.""" if not self._store: @@ -188,73 +189,73 @@ class CronService: for job in self._store.jobs: if job.enabled: job.state.next_run_at_ms = _compute_next_run(job.schedule, now) - + def _get_next_wake_ms(self) -> int | None: """Get the earliest next run time across all jobs.""" if not self._store: return None - times = [j.state.next_run_at_ms for j in self._store.jobs + times = [j.state.next_run_at_ms for j in self._store.jobs if j.enabled and j.state.next_run_at_ms] return min(times) if times else None - + def _arm_timer(self) -> None: """Schedule the next timer tick.""" if self._timer_task: self._timer_task.cancel() - + next_wake = self._get_next_wake_ms() if not next_wake or not self._running: return - + delay_ms = max(0, next_wake - _now_ms()) delay_s = delay_ms / 1000 - + async def tick(): await asyncio.sleep(delay_s) if self._running: await self._on_timer() - + self._timer_task = asyncio.create_task(tick()) - + async def _on_timer(self) -> None: """Handle timer tick - run due jobs.""" if not self._store: return - + now = _now_ms() due_jobs = [ j for j in self._store.jobs if j.enabled and j.state.next_run_at_ms and now >= j.state.next_run_at_ms ] - + for job in due_jobs: await self._execute_job(job) - + self._save_store() self._arm_timer() - + async def _execute_job(self, job: CronJob) -> None: """Execute a single job.""" start_ms = _now_ms() logger.info("Cron: executing job '{}' ({})", job.name, job.id) - + try: response = None if self.on_job: response = await self.on_job(job) - + job.state.last_status = "ok" job.state.last_error = None logger.info("Cron: job '{}' completed", job.name) - + except Exception as e: job.state.last_status = "error" job.state.last_error = str(e) logger.error("Cron: job '{}' failed: {}", job.name, e) - + job.state.last_run_at_ms = start_ms job.updated_at_ms = _now_ms() - + # Handle one-shot jobs if job.schedule.kind == "at": if job.delete_after_run: @@ -265,15 +266,15 @@ class CronService: else: # Compute next run job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms()) - + # ========== Public API ========== - + def list_jobs(self, include_disabled: bool = False) -> list[CronJob]: """List all jobs.""" store = self._load_store() jobs = store.jobs if include_disabled else [j for j in store.jobs if j.enabled] return sorted(jobs, key=lambda j: j.state.next_run_at_ms or float('inf')) - + def add_job( self, name: str, @@ -288,7 +289,7 @@ class CronService: store = self._load_store() _validate_schedule_for_add(schedule) now = _now_ms() - + job = CronJob( id=str(uuid.uuid4())[:8], name=name, @@ -306,28 +307,28 @@ class CronService: updated_at_ms=now, delete_after_run=delete_after_run, ) - + store.jobs.append(job) self._save_store() self._arm_timer() - + logger.info("Cron: added job '{}' ({})", name, job.id) return job - + def remove_job(self, job_id: str) -> bool: """Remove a job by ID.""" store = self._load_store() before = len(store.jobs) store.jobs = [j for j in store.jobs if j.id != job_id] removed = len(store.jobs) < before - + if removed: self._save_store() self._arm_timer() logger.info("Cron: removed job {}", job_id) - + return removed - + def enable_job(self, job_id: str, enabled: bool = True) -> CronJob | None: """Enable or disable a job.""" store = self._load_store() @@ -343,7 +344,7 @@ class CronService: self._arm_timer() return job return None - + async def run_job(self, job_id: str, force: bool = False) -> bool: """Manually run a job.""" store = self._load_store() @@ -356,7 +357,7 @@ class CronService: self._arm_timer() return True return False - + def status(self) -> dict: """Get service status.""" store = self._load_store() diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index eb1599a70..a46e68f61 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -21,7 +21,7 @@ class LLMResponse: finish_reason: str = "stop" usage: dict[str, int] = field(default_factory=dict) reasoning_content: str | None = None # Kimi, DeepSeek-R1 etc. - + @property def has_tool_calls(self) -> bool: """Check if response contains tool calls.""" @@ -35,7 +35,7 @@ class LLMProvider(ABC): Implementations should handle the specifics of each provider's API while maintaining a consistent interface. """ - + def __init__(self, api_key: str | None = None, api_base: str | None = None): self.api_key = api_key self.api_base = api_base @@ -79,7 +79,7 @@ class LLMProvider(ABC): result.append(msg) return result - + @abstractmethod async def chat( self, @@ -103,7 +103,7 @@ class LLMProvider(ABC): LLMResponse with content and/or tool calls. """ pass - + @abstractmethod def get_default_model(self) -> str: """Get the default model for this provider.""" diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 5427d976e..931e0386a 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -1,19 +1,17 @@ """LiteLLM provider implementation for multi-provider support.""" -import json -import json_repair import os import secrets import string from typing import Any +import json_repair import litellm from litellm import acompletion from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest from nanobot.providers.registry import find_by_model, find_gateway - # Standard OpenAI chat-completion message keys plus reasoning_content for # thinking-enabled models (Kimi k2.5, DeepSeek-R1, etc.). _ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content"}) @@ -32,10 +30,10 @@ class LiteLLMProvider(LLMProvider): a unified interface. Provider-specific logic is driven by the registry (see providers/registry.py) โ€” no if-elif chains needed here. """ - + def __init__( - self, - api_key: str | None = None, + self, + api_key: str | None = None, api_base: str | None = None, default_model: str = "anthropic/claude-opus-4-5", extra_headers: dict[str, str] | None = None, @@ -44,24 +42,24 @@ class LiteLLMProvider(LLMProvider): super().__init__(api_key, api_base) self.default_model = default_model self.extra_headers = extra_headers or {} - + # Detect gateway / local deployment. # provider_name (from config key) is the primary signal; # api_key / api_base are fallback for auto-detection. self._gateway = find_gateway(provider_name, api_key, api_base) - + # Configure environment variables if api_key: self._setup_env(api_key, api_base, default_model) - + if api_base: litellm.api_base = api_base - + # Disable LiteLLM logging noise litellm.suppress_debug_info = True # Drop unsupported parameters for providers (e.g., gpt-5 rejects some params) litellm.drop_params = True - + def _setup_env(self, api_key: str, api_base: str | None, model: str) -> None: """Set environment variables based on detected provider.""" spec = self._gateway or find_by_model(model) @@ -85,7 +83,7 @@ class LiteLLMProvider(LLMProvider): resolved = env_val.replace("{api_key}", api_key) resolved = resolved.replace("{api_base}", effective_base) os.environ.setdefault(env_name, resolved) - + def _resolve_model(self, model: str) -> str: """Resolve model name by applying provider/gateway prefixes.""" if self._gateway: @@ -96,7 +94,7 @@ class LiteLLMProvider(LLMProvider): if prefix and not model.startswith(f"{prefix}/"): model = f"{prefix}/{model}" return model - + # Standard mode: auto-prefix for known providers spec = find_by_model(model) if spec and spec.litellm_prefix: @@ -115,7 +113,7 @@ class LiteLLMProvider(LLMProvider): if prefix.lower().replace("-", "_") != spec_name: return model return f"{canonical_prefix}/{remainder}" - + 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: @@ -158,7 +156,7 @@ class LiteLLMProvider(LLMProvider): if pattern in model_lower: kwargs.update(overrides) return - + @staticmethod def _sanitize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: """Strip non-standard keys and ensure assistant messages have a content key.""" @@ -181,14 +179,14 @@ class LiteLLMProvider(LLMProvider): ) -> LLMResponse: """ Send a chat completion request via LiteLLM. - + Args: messages: List of message dicts with 'role' and 'content'. tools: Optional list of tool definitions in OpenAI format. model: Model identifier (e.g., 'anthropic/claude-sonnet-4-5'). max_tokens: Maximum tokens in response. temperature: Sampling temperature. - + Returns: LLMResponse with content and/or tool calls. """ @@ -201,33 +199,33 @@ class LiteLLMProvider(LLMProvider): # Clamp max_tokens to at least 1 โ€” negative or zero values cause # LiteLLM to reject the request with "max_tokens must be at least 1". max_tokens = max(1, max_tokens) - + kwargs: dict[str, Any] = { "model": model, "messages": self._sanitize_messages(self._sanitize_empty_content(messages)), "max_tokens": max_tokens, "temperature": temperature, } - + # Apply model-specific overrides (e.g. kimi-k2.5 temperature) self._apply_model_overrides(model, kwargs) - + # Pass api_key directly โ€” more reliable than env vars alone if self.api_key: kwargs["api_key"] = self.api_key - + # Pass api_base for custom endpoints if self.api_base: kwargs["api_base"] = self.api_base - + # Pass extra headers (e.g. APP-Code for AiHubMix) if self.extra_headers: kwargs["extra_headers"] = self.extra_headers - + if tools: kwargs["tools"] = tools kwargs["tool_choice"] = "auto" - + try: response = await acompletion(**kwargs) return self._parse_response(response) @@ -237,12 +235,12 @@ class LiteLLMProvider(LLMProvider): content=f"Error calling LLM: {str(e)}", finish_reason="error", ) - + def _parse_response(self, response: Any) -> LLMResponse: """Parse LiteLLM response into our standard format.""" choice = response.choices[0] message = choice.message - + tool_calls = [] if hasattr(message, "tool_calls") and message.tool_calls: for tc in message.tool_calls: @@ -250,13 +248,13 @@ class LiteLLMProvider(LLMProvider): args = tc.function.arguments if isinstance(args, str): args = json_repair.loads(args) - + tool_calls.append(ToolCallRequest( id=_short_tool_id(), name=tc.function.name, arguments=args, )) - + usage = {} if hasattr(response, "usage") and response.usage: usage = { @@ -264,9 +262,9 @@ class LiteLLMProvider(LLMProvider): "completion_tokens": response.usage.completion_tokens, "total_tokens": response.usage.total_tokens, } - + reasoning_content = getattr(message, "reasoning_content", None) or None - + return LLMResponse( content=message.content, tool_calls=tool_calls, @@ -274,7 +272,7 @@ class LiteLLMProvider(LLMProvider): usage=usage, reasoning_content=reasoning_content, ) - + def get_default_model(self) -> str: """Get the default model.""" return self.default_model diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py index fa2859319..1e4dd8a17 100644 --- a/nanobot/providers/openai_codex_provider.py +++ b/nanobot/providers/openai_codex_provider.py @@ -9,8 +9,8 @@ from typing import Any, AsyncGenerator import httpx from loguru import logger - from oauth_cli_kit import get_token as get_codex_token + from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses" diff --git a/nanobot/providers/transcription.py b/nanobot/providers/transcription.py index 7a3c62809..1c8cb6a3f 100644 --- a/nanobot/providers/transcription.py +++ b/nanobot/providers/transcription.py @@ -2,7 +2,6 @@ import os from pathlib import Path -from typing import Any import httpx from loguru import logger @@ -11,33 +10,33 @@ from loguru import logger class GroqTranscriptionProvider: """ Voice transcription provider using Groq's Whisper API. - + Groq offers extremely fast transcription with a generous free tier. """ - + def __init__(self, api_key: str | None = None): self.api_key = api_key or os.environ.get("GROQ_API_KEY") self.api_url = "https://api.groq.com/openai/v1/audio/transcriptions" - + async def transcribe(self, file_path: str | Path) -> str: """ Transcribe an audio file using Groq. - + Args: file_path: Path to the audio file. - + Returns: Transcribed text. """ if not self.api_key: logger.warning("Groq API key not configured for transcription") return "" - + path = Path(file_path) if not path.exists(): logger.error("Audio file not found: {}", file_path) return "" - + try: async with httpx.AsyncClient() as client: with open(path, "rb") as f: @@ -48,18 +47,18 @@ class GroqTranscriptionProvider: headers = { "Authorization": f"Bearer {self.api_key}", } - + response = await client.post( self.api_url, headers=headers, files=files, timeout=60.0 ) - + response.raise_for_status() data = response.json() return data.get("text", "") - + except Exception as e: logger.error("Groq transcription error: {}", e) return "" diff --git a/nanobot/session/__init__.py b/nanobot/session/__init__.py index 3faf424b8..931f7c697 100644 --- a/nanobot/session/__init__.py +++ b/nanobot/session/__init__.py @@ -1,5 +1,5 @@ """Session management module.""" -from nanobot.session.manager import SessionManager, Session +from nanobot.session.manager import Session, SessionManager __all__ = ["SessionManager", "Session"] diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index d59b7c9a6..dce4b2ec4 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -2,9 +2,9 @@ import json import shutil -from pathlib import Path from dataclasses import dataclass, field from datetime import datetime +from pathlib import Path from typing import Any from loguru import logger @@ -30,7 +30,7 @@ class Session: updated_at: datetime = field(default_factory=datetime.now) metadata: dict[str, Any] = field(default_factory=dict) last_consolidated: int = 0 # Number of messages already consolidated to files - + def add_message(self, role: str, content: str, **kwargs: Any) -> None: """Add a message to the session.""" msg = { @@ -41,7 +41,7 @@ class Session: } self.messages.append(msg) self.updated_at = datetime.now() - + def get_history(self, max_messages: int = 500) -> list[dict[str, Any]]: """Return unconsolidated messages for LLM input, aligned to a user turn.""" unconsolidated = self.messages[self.last_consolidated:] @@ -61,7 +61,7 @@ class Session: entry[k] = m[k] out.append(entry) return out - + def clear(self) -> None: """Clear all messages and reset session to initial state.""" self.messages = [] @@ -81,7 +81,7 @@ class SessionManager: self.sessions_dir = ensure_dir(self.workspace / "sessions") self.legacy_sessions_dir = Path.home() / ".nanobot" / "sessions" self._cache: dict[str, Session] = {} - + def _get_session_path(self, key: str) -> Path: """Get the file path for a session.""" safe_key = safe_filename(key.replace(":", "_")) @@ -91,27 +91,27 @@ class SessionManager: """Legacy global session path (~/.nanobot/sessions/).""" safe_key = safe_filename(key.replace(":", "_")) return self.legacy_sessions_dir / f"{safe_key}.jsonl" - + def get_or_create(self, key: str) -> Session: """ Get an existing session or create a new one. - + Args: key: Session key (usually channel:chat_id). - + Returns: The session. """ if key in self._cache: return self._cache[key] - + session = self._load(key) if session is None: session = Session(key=key) - + self._cache[key] = session return session - + def _load(self, key: str) -> Session | None: """Load a session from disk.""" path = self._get_session_path(key) @@ -158,7 +158,7 @@ class SessionManager: except Exception as e: logger.warning("Failed to load session {}: {}", key, e) return None - + def save(self, session: Session) -> None: """Save a session to disk.""" path = self._get_session_path(session.key) @@ -177,20 +177,20 @@ class SessionManager: f.write(json.dumps(msg, ensure_ascii=False) + "\n") self._cache[session.key] = session - + def invalidate(self, key: str) -> None: """Remove a session from the in-memory cache.""" self._cache.pop(key, None) - + def list_sessions(self) -> list[dict[str, Any]]: """ List all sessions. - + Returns: List of session info dicts. """ sessions = [] - + for path in self.sessions_dir.glob("*.jsonl"): try: # Read just the metadata line @@ -208,5 +208,5 @@ class SessionManager: }) except Exception: continue - + return sorted(sessions, key=lambda x: x.get("updated_at", ""), reverse=True) diff --git a/nanobot/utils/__init__.py b/nanobot/utils/__init__.py index 744498723..9163e388d 100644 --- a/nanobot/utils/__init__.py +++ b/nanobot/utils/__init__.py @@ -1,5 +1,5 @@ """Utility functions for nanobot.""" -from nanobot.utils.helpers import ensure_dir, get_workspace_path, get_data_path +from nanobot.utils.helpers import ensure_dir, get_data_path, get_workspace_path __all__ = ["ensure_dir", "get_workspace_path", "get_data_path"] diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index 8322bc87b..3a8c8023f 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -1,8 +1,8 @@ """Utility functions for nanobot.""" import re -from pathlib import Path from datetime import datetime +from pathlib import Path def ensure_dir(path: Path) -> Path: From b3af59fc8e09fd6acc0af8f0bddcadec64ce7d42 Mon Sep 17 00:00:00 2001 From: "zhangxiaoyu.york" Date: Sun, 1 Mar 2026 00:20:32 +0800 Subject: [PATCH 07/84] bugfix: remove client.stop --- nanobot/channels/feishu.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 4a6312ec9..4abac85e3 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -311,8 +311,8 @@ class FeishuChannel(BaseChannel): self._ws_client.start() except Exception as e: logger.warning("Feishu WebSocket error: {}", e) - if self._running: - import time; time.sleep(5) + if self._running: + import time; time.sleep(5) self._ws_thread = threading.Thread(target=run_ws, daemon=True) self._ws_thread.start() @@ -327,11 +327,6 @@ class FeishuChannel(BaseChannel): async def stop(self) -> None: """Stop the Feishu bot.""" self._running = False - if self._ws_client: - try: - self._ws_client.stop() - except Exception as e: - logger.warning("Error stopping WebSocket client: {}", e) logger.info("Feishu bot stopped") def _add_reaction_sync(self, message_id: str, emoji_type: str) -> None: From 73a708770e3a2e7331ae61100778cd4d78ced5c4 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 28 Feb 2026 16:23:43 +0000 Subject: [PATCH 08/84] refactor: compress DingTalk helpers --- nanobot/channels/dingtalk.py | 77 ++++++++---------------------------- 1 file changed, 16 insertions(+), 61 deletions(-) diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py index 53a9bb85c..279702996 100644 --- a/nanobot/channels/dingtalk.py +++ b/nanobot/channels/dingtalk.py @@ -200,34 +200,18 @@ class DingTalkChannel(BaseChannel): @staticmethod def _is_http_url(value: str) -> bool: - low = value.lower() - return low.startswith("http://") or low.startswith("https://") + return urlparse(value).scheme in ("http", "https") def _guess_upload_type(self, media_ref: str) -> str: - parsed = urlparse(media_ref) - path = parsed.path if parsed.scheme else media_ref - ext = Path(path).suffix.lower() - if ext in self._IMAGE_EXTS: - return "image" - if ext in self._AUDIO_EXTS: - return "voice" - if ext in self._VIDEO_EXTS: - return "video" + ext = Path(urlparse(media_ref).path).suffix.lower() + if ext in self._IMAGE_EXTS: return "image" + if ext in self._AUDIO_EXTS: return "voice" + if ext in self._VIDEO_EXTS: return "video" return "file" def _guess_filename(self, media_ref: str, upload_type: str) -> str: - parsed = urlparse(media_ref) - path = parsed.path if parsed.scheme else media_ref - name = os.path.basename(path) - if name: - return name - fallback = { - "image": "image.jpg", - "voice": "audio.amr", - "video": "video.mp4", - "file": "file.bin", - } - return fallback.get(upload_type, "file.bin") + name = os.path.basename(urlparse(media_ref).path) + return name or {"image": "image.jpg", "voice": "audio.amr", "video": "video.mp4"}.get(upload_type, "file.bin") async def _read_media_bytes( self, @@ -288,33 +272,16 @@ class DingTalkChannel(BaseChannel): try: resp = await self._http.post(url, files=files) text = resp.text - try: - result = resp.json() - except Exception: - result = {} + result = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {} if resp.status_code >= 400: - logger.error( - "DingTalk media upload failed status={} type={} body={}", - resp.status_code, - media_type, - text[:500], - ) + logger.error("DingTalk media upload failed status={} type={} body={}", resp.status_code, media_type, text[:500]) return None errcode = result.get("errcode", 0) if errcode != 0: - logger.error( - "DingTalk media upload api error type={} errcode={} body={}", - media_type, - errcode, - text[:500], - ) + logger.error("DingTalk media upload api error type={} errcode={} body={}", media_type, errcode, text[:500]) return None - media_id = ( - result.get("media_id") - or result.get("mediaId") - or (result.get("result") or {}).get("media_id") - or (result.get("result") or {}).get("mediaId") - ) + sub = result.get("result") or {} + media_id = result.get("media_id") or result.get("mediaId") or sub.get("media_id") or sub.get("mediaId") if not media_id: logger.error("DingTalk media upload missing media_id body={}", text[:500]) return None @@ -347,25 +314,13 @@ class DingTalkChannel(BaseChannel): resp = await self._http.post(url, json=payload, headers=headers) body = resp.text if resp.status_code != 200: - logger.error( - "DingTalk send failed msgKey={} status={} body={}", - msg_key, - resp.status_code, - body[:500], - ) + logger.error("DingTalk send failed msgKey={} status={} body={}", msg_key, resp.status_code, body[:500]) return False - try: - result = resp.json() - except Exception: - result = {} + try: result = resp.json() + except Exception: result = {} errcode = result.get("errcode") if errcode not in (None, 0): - logger.error( - "DingTalk send api error msgKey={} errcode={} body={}", - msg_key, - errcode, - body[:500], - ) + logger.error("DingTalk send api error msgKey={} errcode={} body={}", msg_key, errcode, body[:500]) return False logger.debug("DingTalk message sent to {} with msgKey={}", chat_id, msg_key) return True From 5d829ca575464214b18b8af27d326a2db967e922 Mon Sep 17 00:00:00 2001 From: "zhangxiaoyu.york" Date: Sun, 1 Mar 2026 00:30:03 +0800 Subject: [PATCH 09/84] bugfix: remove client.stop --- nanobot/channels/feishu.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 4abac85e3..161d31e18 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -325,7 +325,13 @@ class FeishuChannel(BaseChannel): await asyncio.sleep(1) async def stop(self) -> None: - """Stop the Feishu bot.""" + """ + Stop the Feishu bot. + + Notice: lark.ws.Client does not expose stop method๏ผŒ simply exiting the program will close the client. + + Reference: https://github.com/larksuite/oapi-sdk-python/blob/v2_main/lark_oapi/ws/client.py#L86 + """ self._running = False logger.info("Feishu bot stopped") From 8545d5790ebf0979eec3a96b850f12b7967688c3 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 28 Feb 2026 16:32:50 +0000 Subject: [PATCH 10/84] refactor: streamline subagent prompt by reusing ContextBuilder and SkillsLoader --- README.md | 2 +- nanobot/agent/subagent.py | 44 +++++++++++++-------------------------- 2 files changed, 15 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index d788e5ec5..66da385f8 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,922 lines** (run `bash core_agent_lines.sh` to verify anytime) +๐Ÿ“ Real-time line count: **3,927 lines** (run `bash core_agent_lines.sh` to verify anytime) ## ๐Ÿ“ข News diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 337796c94..56063036d 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -104,8 +104,7 @@ class SubagentManager: tools.register(WebSearchTool(api_key=self.brave_api_key)) tools.register(WebFetchTool()) - # Build messages with subagent-specific prompt - system_prompt = self._build_subagent_prompt(task) + system_prompt = self._build_subagent_prompt() messages: list[dict[str, Any]] = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": task}, @@ -204,42 +203,27 @@ Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not men await self.bus.publish_inbound(msg) logger.debug("Subagent [{}] announced result to {}:{}", task_id, origin['channel'], origin['chat_id']) - def _build_subagent_prompt(self, task: str) -> str: + def _build_subagent_prompt(self) -> str: """Build a focused system prompt for the subagent.""" - from datetime import datetime - import time as _time - now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)") - tz = _time.strftime("%Z") or "UTC" + from nanobot.agent.context import ContextBuilder + from nanobot.agent.skills import SkillsLoader - return f"""# Subagent + time_ctx = ContextBuilder._build_runtime_context(None, None) + parts = [f"""# Subagent -## Current Time -{now} ({tz}) +{time_ctx} You are a subagent spawned by the main agent to complete a specific task. - -## Rules -1. Stay focused - complete only the assigned task, nothing else -2. Your final response will be reported back to the main agent -3. Do not initiate conversations or take on side tasks -4. Be concise but informative in your findings - -## What You Can Do -- Read and write files in the workspace -- Execute shell commands -- Search the web and fetch web pages -- Complete the task thoroughly - -## What You Cannot Do -- Send messages directly to users (no message tool available) -- Spawn other subagents -- Access the main agent's conversation history +Stay focused on the assigned task. Your final response will be reported back to the main agent. ## Workspace -Your workspace is at: {self.workspace} -Skills are available at: {self.workspace}/skills/ (read SKILL.md files as needed) +{self.workspace}"""] -When you have completed the task, provide a clear summary of your findings or actions.""" + skills_summary = SkillsLoader(self.workspace).build_skills_summary() + if skills_summary: + parts.append(f"## Skills\n\nRead SKILL.md with read_file to use a skill.\n\n{skills_summary}") + + return "\n\n".join(parts) async def cancel_by_session(self, session_key: str) -> int: """Cancel all subagents for the given session. Returns count cancelled.""" From cfe33ff7cd321813b03d1bc88a18bffc811dbeb9 Mon Sep 17 00:00:00 2001 From: zerone0x Date: Sat, 28 Feb 2026 17:35:07 +0100 Subject: [PATCH 11/84] fix(qq): disable botpy file log to fix read-only filesystem error When nanobot is run as a systemd service with ProtectSystem=strict, the process cwd defaults to the read-only root filesystem (/). botpy's default Client configuration includes a TimedRotatingFileHandler that writes 'botpy.log' to os.getcwd(), which raises [Errno 30] Read-only file system. Pass ext_handlers=False when constructing the botpy Client subclass to suppress the file handler. nanobot already routes all log output through loguru, so botpy's file handler is redundant. Fixes #1343 Co-Authored-By: Claude --- nanobot/channels/qq.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 50dbbded5..41e6ad3ba 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -31,7 +31,13 @@ def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]": class _Bot(botpy.Client): def __init__(self): - super().__init__(intents=intents) + # Disable botpy's default file handler (TimedRotatingFileHandler). + # By default botpy writes "botpy.log" to the process cwd, which + # fails under systemd with ProtectSystem=strict (read-only root fs). + # nanobot already handles logging via loguru, so the file handler is + # redundant. ext_handlers=False keeps console output but suppresses + # the file log. See: https://github.com/HKUDS/nanobot/issues/1343 + super().__init__(intents=intents, ext_handlers=False) async def on_ready(self): logger.info("QQ bot ready: {}", self.robot.name) From c34e1053f05ec8f96f68904dcd26fbf86e654afd Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 28 Feb 2026 16:45:06 +0000 Subject: [PATCH 12/84] fix(qq): disable botpy file log to fix read-only filesystem error --- nanobot/channels/qq.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 41e6ad3ba..7b171bc45 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -31,12 +31,7 @@ def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]": class _Bot(botpy.Client): def __init__(self): - # Disable botpy's default file handler (TimedRotatingFileHandler). - # By default botpy writes "botpy.log" to the process cwd, which - # fails under systemd with ProtectSystem=strict (read-only root fs). - # nanobot already handles logging via loguru, so the file handler is - # redundant. ext_handlers=False keeps console output but suppresses - # the file log. See: https://github.com/HKUDS/nanobot/issues/1343 + # Disable botpy's file log โ€” nanobot uses loguru; default "botpy.log" fails on read-only fs super().__init__(intents=intents, ext_handlers=False) async def on_ready(self): From 9e2f69bd5a069c8e7b7a2288fa7e004a4409cec5 Mon Sep 17 00:00:00 2001 From: "zhangxiaoyu.york" Date: Sun, 1 Mar 2026 00:51:17 +0800 Subject: [PATCH 13/84] tidy up --- nanobot/channels/feishu.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 161d31e18..16c6a0716 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -311,8 +311,8 @@ class FeishuChannel(BaseChannel): self._ws_client.start() except Exception as e: logger.warning("Feishu WebSocket error: {}", e) - if self._running: - import time; time.sleep(5) + if self._running: + import time; time.sleep(5) self._ws_thread = threading.Thread(target=run_ws, daemon=True) self._ws_thread.start() From f9d72e2e74cb4177ed892b66fdf4dd639690793c Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 28 Feb 2026 17:18:05 +0000 Subject: [PATCH 14/84] feat: add reasoning_effort config to enable LLM thinking mode --- README.md | 2 +- nanobot/agent/loop.py | 4 ++++ nanobot/agent/subagent.py | 3 +++ nanobot/cli/commands.py | 3 +++ nanobot/config/schema.py | 1 + nanobot/providers/base.py | 1 + nanobot/providers/custom_provider.py | 5 ++++- nanobot/providers/litellm_provider.py | 5 +++++ nanobot/providers/openai_codex_provider.py | 1 + 9 files changed, 23 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 66da385f8..0d46b7fb2 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,927 lines** (run `bash core_agent_lines.sh` to verify anytime) +๐Ÿ“ Real-time line count: **3,935 lines** (run `bash core_agent_lines.sh` to verify anytime) ## ๐Ÿ“ข News diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index d8e5cad20..b42c3ba49 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -56,6 +56,7 @@ class AgentLoop: temperature: float = 0.1, max_tokens: int = 4096, memory_window: int = 100, + reasoning_effort: str | None = None, brave_api_key: str | None = None, exec_config: ExecToolConfig | None = None, cron_service: CronService | None = None, @@ -74,6 +75,7 @@ class AgentLoop: self.temperature = temperature self.max_tokens = max_tokens self.memory_window = memory_window + self.reasoning_effort = reasoning_effort self.brave_api_key = brave_api_key self.exec_config = exec_config or ExecToolConfig() self.cron_service = cron_service @@ -89,6 +91,7 @@ class AgentLoop: model=self.model, temperature=self.temperature, max_tokens=self.max_tokens, + reasoning_effort=reasoning_effort, brave_api_key=brave_api_key, exec_config=self.exec_config, restrict_to_workspace=restrict_to_workspace, @@ -191,6 +194,7 @@ class AgentLoop: model=self.model, temperature=self.temperature, max_tokens=self.max_tokens, + reasoning_effort=self.reasoning_effort, ) if response.has_tool_calls: diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 56063036d..a99ba4d94 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -28,6 +28,7 @@ class SubagentManager: model: str | None = None, temperature: float = 0.7, max_tokens: int = 4096, + reasoning_effort: str | None = None, brave_api_key: str | None = None, exec_config: "ExecToolConfig | None" = None, restrict_to_workspace: bool = False, @@ -39,6 +40,7 @@ class SubagentManager: self.model = model or provider.get_default_model() self.temperature = temperature self.max_tokens = max_tokens + self.reasoning_effort = reasoning_effort self.brave_api_key = brave_api_key self.exec_config = exec_config or ExecToolConfig() self.restrict_to_workspace = restrict_to_workspace @@ -124,6 +126,7 @@ class SubagentManager: model=self.model, temperature=self.temperature, max_tokens=self.max_tokens, + reasoning_effort=self.reasoning_effort, ) if response.has_tool_calls: diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index fc4c261ea..2e417d6e7 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -283,6 +283,7 @@ def gateway( max_tokens=config.agents.defaults.max_tokens, max_iterations=config.agents.defaults.max_tool_iterations, memory_window=config.agents.defaults.memory_window, + reasoning_effort=config.agents.defaults.reasoning_effort, brave_api_key=config.tools.web.search.api_key or None, exec_config=config.tools.exec, cron_service=cron, @@ -441,6 +442,7 @@ def agent( max_tokens=config.agents.defaults.max_tokens, max_iterations=config.agents.defaults.max_tool_iterations, memory_window=config.agents.defaults.memory_window, + reasoning_effort=config.agents.defaults.reasoning_effort, brave_api_key=config.tools.web.search.api_key or None, exec_config=config.tools.exec, cron_service=cron, @@ -932,6 +934,7 @@ def cron_run( max_tokens=config.agents.defaults.max_tokens, max_iterations=config.agents.defaults.max_tool_iterations, memory_window=config.agents.defaults.memory_window, + reasoning_effort=config.agents.defaults.reasoning_effort, brave_api_key=config.tools.web.search.api_key or None, exec_config=config.tools.exec, restrict_to_workspace=config.tools.restrict_to_workspace, diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 1ff9782f2..4f06ebe37 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -226,6 +226,7 @@ class AgentDefaults(Base): temperature: float = 0.1 max_tool_iterations: int = 40 memory_window: int = 100 + reasoning_effort: str | None = None # low / medium / high โ€” enables LLM thinking mode class AgentsConfig(Base): diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index eb1599a70..36e993854 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -88,6 +88,7 @@ class LLMProvider(ABC): model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7, + reasoning_effort: str | None = None, ) -> LLMResponse: """ Send a chat completion request. diff --git a/nanobot/providers/custom_provider.py b/nanobot/providers/custom_provider.py index a578d1413..56e6270f9 100644 --- a/nanobot/providers/custom_provider.py +++ b/nanobot/providers/custom_provider.py @@ -18,13 +18,16 @@ class CustomProvider(LLMProvider): self._client = AsyncOpenAI(api_key=api_key, base_url=api_base) async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None, - model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7) -> LLMResponse: + model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7, + reasoning_effort: str | None = None) -> LLMResponse: kwargs: dict[str, Any] = { "model": model or self.default_model, "messages": self._sanitize_empty_content(messages), "max_tokens": max(1, max_tokens), "temperature": temperature, } + if reasoning_effort: + kwargs["reasoning_effort"] = reasoning_effort if tools: kwargs.update(tools=tools, tool_choice="auto") try: diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 5427d976e..0067ae81a 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -178,6 +178,7 @@ class LiteLLMProvider(LLMProvider): model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7, + reasoning_effort: str | None = None, ) -> LLMResponse: """ Send a chat completion request via LiteLLM. @@ -224,6 +225,10 @@ class LiteLLMProvider(LLMProvider): if self.extra_headers: kwargs["extra_headers"] = self.extra_headers + if reasoning_effort: + kwargs["reasoning_effort"] = reasoning_effort + kwargs["drop_params"] = True + if tools: kwargs["tools"] = tools kwargs["tool_choice"] = "auto" diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py index fa2859319..9039202a2 100644 --- a/nanobot/providers/openai_codex_provider.py +++ b/nanobot/providers/openai_codex_provider.py @@ -31,6 +31,7 @@ class OpenAICodexProvider(LLMProvider): model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7, + reasoning_effort: str | None = None, ) -> LLMResponse: model = model or self.default_model system_prompt, input_items = _convert_messages(messages) From 5ca386ebf52f36441b44dacd85072d79aea0dd98 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 28 Feb 2026 17:37:12 +0000 Subject: [PATCH 15/84] fix: preserve reasoning_content and thinking_blocks in session history --- nanobot/agent/context.py | 3 +++ nanobot/agent/loop.py | 4 +++- nanobot/providers/base.py | 1 + nanobot/providers/litellm_provider.py | 4 +++- 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index be0ec5996..a469bc846 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -150,6 +150,7 @@ Reply directly with text for conversations. Only use the 'message' tool to send content: str | None, tool_calls: list[dict[str, Any]] | None = None, reasoning_content: str | None = None, + thinking_blocks: list[dict] | None = None, ) -> list[dict[str, Any]]: """Add an assistant message to the message list.""" msg: dict[str, Any] = {"role": "assistant", "content": content} @@ -157,5 +158,7 @@ Reply directly with text for conversations. Only use the 'message' tool to send msg["tool_calls"] = tool_calls if reasoning_content is not None: msg["reasoning_content"] = reasoning_content + if thinking_blocks: + msg["thinking_blocks"] = thinking_blocks messages.append(msg) return messages diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index b42c3ba49..8da9fcb1c 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -218,6 +218,7 @@ class AgentLoop: messages = self.context.add_assistant_message( messages, response.content, tool_call_dicts, reasoning_content=response.reasoning_content, + thinking_blocks=response.thinking_blocks, ) for tool_call in response.tool_calls: @@ -238,6 +239,7 @@ class AgentLoop: break messages = self.context.add_assistant_message( messages, clean, reasoning_content=response.reasoning_content, + thinking_blocks=response.thinking_blocks, ) final_content = clean break @@ -451,7 +453,7 @@ class AgentLoop: """Save new-turn messages into session, truncating large tool results.""" from datetime import datetime for m in messages[skip:]: - entry = {k: v for k, v in m.items() if k != "reasoning_content"} + entry = dict(m) role, content = entry.get("role"), entry.get("content") if role == "assistant" and not content and not entry.get("tool_calls"): continue # skip empty assistant messages โ€” they poison session context diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index 36e993854..25932a314 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -21,6 +21,7 @@ class LLMResponse: finish_reason: str = "stop" usage: dict[str, int] = field(default_factory=dict) reasoning_content: str | None = None # Kimi, DeepSeek-R1 etc. + thinking_blocks: list[dict] | None = None # Anthropic extended thinking @property def has_tool_calls(self) -> bool: diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 0067ae81a..aff2ac7bf 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -16,7 +16,7 @@ from nanobot.providers.registry import find_by_model, find_gateway # Standard OpenAI chat-completion message keys plus reasoning_content for # thinking-enabled models (Kimi k2.5, DeepSeek-R1, etc.). -_ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content"}) +_ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content", "thinking_blocks"}) _ALNUM = string.ascii_letters + string.digits def _short_tool_id() -> str: @@ -271,6 +271,7 @@ class LiteLLMProvider(LLMProvider): } reasoning_content = getattr(message, "reasoning_content", None) or None + thinking_blocks = getattr(message, "thinking_blocks", None) or None return LLMResponse( content=message.content, @@ -278,6 +279,7 @@ class LiteLLMProvider(LLMProvider): finish_reason=choice.finish_reason or "stop", usage=usage, reasoning_content=reasoning_content, + thinking_blocks=thinking_blocks, ) def get_default_model(self) -> str: From 4f0530dd6147b057ef44af278ee577cf21ecebd5 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 28 Feb 2026 17:55:18 +0000 Subject: [PATCH 16/84] release: v0.1.4.post3 --- nanobot/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nanobot/__init__.py b/nanobot/__init__.py index bb9bfb651..4dba5f41b 100644 --- a/nanobot/__init__.py +++ b/nanobot/__init__.py @@ -2,5 +2,5 @@ nanobot - A lightweight AI agent framework """ -__version__ = "0.1.4.post2" +__version__ = "0.1.4.post3" __logo__ = "๐Ÿˆ" diff --git a/pyproject.toml b/pyproject.toml index 20dcb1e01..a22053c49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nanobot-ai" -version = "0.1.4.post2" +version = "0.1.4.post3" description = "A lightweight personal AI assistant framework" requires-python = ">=3.11" license = {text = "MIT"} From ee9bd6a96c736295b54878f65a9489260a222c7d Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 28 Feb 2026 18:04:12 +0000 Subject: [PATCH 17/84] docs: update v0.1.4.post3 release news --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 0d46b7fb2..4ae9aa20b 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,10 @@ ## ๐Ÿ“ข News +- **2026-02-28** ๐Ÿš€ Released **v0.1.4.post3** โ€” cleaner context, hardened session history, and smarter agent. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post3) for details. +- **2026-02-27** ๐Ÿง  Experimental thinking mode support, DingTalk media messages, Feishu and QQ channel fixes. +- **2026-02-26** ๐Ÿ›ก๏ธ Session poisoning fix, WhatsApp dedup, Windows path guard, Mistral compatibility. +- **2026-02-25** ๐Ÿงน New Matrix channel, cleaner session context, auto workspace template sync. - **2026-02-24** ๐Ÿš€ Released **v0.1.4.post2** โ€” a reliability-focused release with a redesigned heartbeat, prompt cache optimization, and hardened provider & channel stability. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post2) for details. - **2026-02-23** ๐Ÿ”ง Virtual tool-call heartbeat, prompt cache optimization, Slack mrkdwn fixes. - **2026-02-22** ๐Ÿ›ก๏ธ Slack thread isolation, Discord typing fix, agent reliability improvements. From f172c9f381980a870ac47283a58136f09314b184 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 28 Feb 2026 18:06:56 +0000 Subject: [PATCH 18/84] docs: reformat release news with v0.1.4.post3 release --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4ae9aa20b..45779e727 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ ## ๐Ÿ“ข News -- **2026-02-28** ๐Ÿš€ Released **v0.1.4.post3** โ€” cleaner context, hardened session history, and smarter agent. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post3) for details. +- **2026-02-28** ๐Ÿš€ Released **v0.1.4.post3** โ€” cleaner context, hardened session history, and smarter agent. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post3) for details. - **2026-02-27** ๐Ÿง  Experimental thinking mode support, DingTalk media messages, Feishu and QQ channel fixes. - **2026-02-26** ๐Ÿ›ก๏ธ Session poisoning fix, WhatsApp dedup, Windows path guard, Mistral compatibility. - **2026-02-25** ๐Ÿงน New Matrix channel, cleaner session context, auto workspace template sync. @@ -30,6 +30,10 @@ - **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. + +
+Earlier news + - **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. @@ -38,10 +42,6 @@ - **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! - -
-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). From 2fc16596d0c0a0d255417507f544d4405365be4f Mon Sep 17 00:00:00 2001 From: yzchen Date: Sun, 1 Mar 2026 02:17:10 +0800 Subject: [PATCH 19/84] fix(feishu): parse post wrapper payload for rich text messages --- nanobot/channels/feishu.py | 20 +++++++++++++--- tests/test_feishu_post_content.py | 40 +++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 tests/test_feishu_post_content.py diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index c632fb7d2..f6ba74a4e 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -221,18 +221,32 @@ def _extract_post_content(content_json: dict) -> tuple[str, list[str]]: text = " ".join(text_parts).strip() if text_parts else None return text, image_keys + # Compatible with both shapes: + # 1) {"post": {"zh_cn": {...}}} + # 2) {"zh_cn": {...}} or {"title": "...", "content": [...]} + post_root = content_json.get("post") if isinstance(content_json, dict) else None + if not isinstance(post_root, dict): + post_root = content_json if isinstance(content_json, dict) else {} + # Try direct format first - if "content" in content_json: - text, images = extract_from_lang(content_json) + if "content" in post_root: + text, images = extract_from_lang(post_root) if text or images: return text or "", images # Try localized format for lang_key in ("zh_cn", "en_us", "ja_jp"): - lang_content = content_json.get(lang_key) + lang_content = post_root.get(lang_key) text, images = extract_from_lang(lang_content) if text or images: return text or "", images + + # Fallback: first dict-shaped child + for value in post_root.values(): + if isinstance(value, dict): + text, images = extract_from_lang(value) + if text or images: + return text or "", images return "", [] diff --git a/tests/test_feishu_post_content.py b/tests/test_feishu_post_content.py new file mode 100644 index 000000000..bf1ea823d --- /dev/null +++ b/tests/test_feishu_post_content.py @@ -0,0 +1,40 @@ +from nanobot.channels.feishu import _extract_post_content + + +def test_extract_post_content_supports_post_wrapper_shape() -> None: + payload = { + "post": { + "zh_cn": { + "title": "ๆ—ฅๆŠฅ", + "content": [ + [ + {"tag": "text", "text": "ๅฎŒๆˆ"}, + {"tag": "img", "image_key": "img_1"}, + ] + ], + } + } + } + + text, image_keys = _extract_post_content(payload) + + assert text == "ๆ—ฅๆŠฅ ๅฎŒๆˆ" + assert image_keys == ["img_1"] + + +def test_extract_post_content_keeps_direct_shape_behavior() -> None: + payload = { + "title": "Daily", + "content": [ + [ + {"tag": "text", "text": "report"}, + {"tag": "img", "image_key": "img_a"}, + {"tag": "img", "image_key": "img_b"}, + ] + ], + } + + text, image_keys = _extract_post_content(payload) + + assert text == "Daily report" + assert image_keys == ["img_a", "img_b"] From 89e5a28097bf70c4f160c384df600e121cd9caea Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 1 Mar 2026 06:01:47 +0000 Subject: [PATCH 20/84] fix(cron): auto-reload jobs.json when modified externally --- nanobot/cron/service.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index 6889a1060..7c7b3e5c2 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -68,13 +68,19 @@ class CronService: on_job: Callable[[CronJob], Coroutine[Any, Any, str | None]] | None = None ): self.store_path = store_path - self.on_job = on_job # Callback to execute job, returns response text + self.on_job = on_job self._store: CronStore | None = None + self._last_mtime: float = 0.0 self._timer_task: asyncio.Task | None = None self._running = False def _load_store(self) -> CronStore: - """Load jobs from disk.""" + """Load jobs from disk. Reloads automatically if file was modified externally.""" + if self._store and self.store_path.exists(): + mtime = self.store_path.stat().st_mtime + if mtime != self._last_mtime: + logger.info("Cron: jobs.json modified externally, reloading") + self._store = None if self._store: return self._store @@ -163,6 +169,7 @@ class CronService: } self.store_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + self._last_mtime = self.store_path.stat().st_mtime async def start(self) -> None: """Start the cron service.""" From 4752e95a24ca52edfcd09e07433d26db81e5645f Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 1 Mar 2026 06:36:29 +0000 Subject: [PATCH 21/84] merge origin/main into pr-1361 --- nanobot/channels/feishu.py | 104 ++++++++++++++++--------------------- 1 file changed, 46 insertions(+), 58 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 6bc0ebde5..0a0a5e492 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -181,71 +181,59 @@ def _extract_element_content(element: dict) -> list[str]: def _extract_post_content(content_json: dict) -> tuple[str, list[str]]: - """Extract text and image keys from Feishu post (rich text) message content. + """Extract text and image keys from Feishu post (rich text) message. - Supports two formats: - 1. Direct format: {"title": "...", "content": [...]} - 2. Localized format: {"zh_cn": {"title": "...", "content": [...]}} - - Returns: - (text, image_keys) - extracted text and list of image keys + Handles three payload shapes: + - Direct: {"title": "...", "content": [[...]]} + - Localized: {"zh_cn": {"title": "...", "content": [...]}} + - Wrapped: {"post": {"zh_cn": {"title": "...", "content": [...]}}} """ - def extract_from_lang(lang_content: dict) -> tuple[str | None, list[str]]: - if not isinstance(lang_content, dict): + + def _parse_block(block: dict) -> tuple[str | None, list[str]]: + if not isinstance(block, dict) or not isinstance(block.get("content"), list): return None, [] - title = lang_content.get("title", "") - content_blocks = lang_content.get("content", []) - if not isinstance(content_blocks, list): - return None, [] - text_parts = [] - image_keys = [] - if title: - text_parts.append(title) - for block in content_blocks: - if not isinstance(block, list): + texts, images = [], [] + if title := block.get("title"): + texts.append(title) + for row in block["content"]: + if not isinstance(row, list): continue - for element in block: - if isinstance(element, dict): - tag = element.get("tag") - if tag == "text": - text_parts.append(element.get("text", "")) - elif tag == "a": - text_parts.append(element.get("text", "")) - elif tag == "at": - text_parts.append(f"@{element.get('user_name', 'user')}") - elif tag == "img": - img_key = element.get("image_key") - if img_key: - image_keys.append(img_key) - text = " ".join(text_parts).strip() if text_parts else None - return text, image_keys + for el in row: + if not isinstance(el, dict): + continue + tag = el.get("tag") + if tag in ("text", "a"): + texts.append(el.get("text", "")) + elif tag == "at": + texts.append(f"@{el.get('user_name', 'user')}") + elif tag == "img" and (key := el.get("image_key")): + images.append(key) + return (" ".join(texts).strip() or None), images - # Compatible with both shapes: - # 1) {"post": {"zh_cn": {...}}} - # 2) {"zh_cn": {...}} or {"title": "...", "content": [...]} - post_root = content_json.get("post") if isinstance(content_json, dict) else None - if not isinstance(post_root, dict): - post_root = content_json if isinstance(content_json, dict) else {} + # Unwrap optional {"post": ...} envelope + root = content_json + if isinstance(root, dict) and isinstance(root.get("post"), dict): + root = root["post"] + if not isinstance(root, dict): + return "", [] - # Try direct format first - if "content" in post_root: - text, images = extract_from_lang(post_root) - if text or images: - return text or "", images + # Direct format + if "content" in root: + text, imgs = _parse_block(root) + if text or imgs: + return text or "", imgs - # Try localized format - for lang_key in ("zh_cn", "en_us", "ja_jp"): - lang_content = post_root.get(lang_key) - text, images = extract_from_lang(lang_content) - if text or images: - return text or "", images - - # Fallback: first dict-shaped child - for value in post_root.values(): - if isinstance(value, dict): - text, images = extract_from_lang(value) - if text or images: - return text or "", images + # Localized: prefer known locales, then fall back to any dict child + for key in ("zh_cn", "en_us", "ja_jp"): + if key in root: + text, imgs = _parse_block(root[key]) + if text or imgs: + return text or "", imgs + for val in root.values(): + if isinstance(val, dict): + text, imgs = _parse_block(val) + if text or imgs: + return text or "", imgs return "", [] From 82be2ae1a5cbf0d2579c4fc346c2562464f85dd8 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Sun, 1 Mar 2026 13:27:46 +0800 Subject: [PATCH 22/84] feat(tool): add web search proxy --- nanobot/agent/loop.py | 7 +++++-- nanobot/agent/subagent.py | 6 ++++-- nanobot/agent/tools/web.py | 30 +++++++++++++++++++++++++----- nanobot/cli/commands.py | 3 +++ nanobot/config/schema.py | 1 + 5 files changed, 38 insertions(+), 9 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 8da9fcb1c..488615d4c 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -58,6 +58,7 @@ class AgentLoop: memory_window: int = 100, reasoning_effort: str | None = None, brave_api_key: str | None = None, + web_proxy: str | None = None, exec_config: ExecToolConfig | None = None, cron_service: CronService | None = None, restrict_to_workspace: bool = False, @@ -77,6 +78,7 @@ class AgentLoop: self.memory_window = memory_window self.reasoning_effort = reasoning_effort self.brave_api_key = brave_api_key + self.web_proxy = web_proxy self.exec_config = exec_config or ExecToolConfig() self.cron_service = cron_service self.restrict_to_workspace = restrict_to_workspace @@ -93,6 +95,7 @@ class AgentLoop: max_tokens=self.max_tokens, reasoning_effort=reasoning_effort, brave_api_key=brave_api_key, + web_proxy=web_proxy, exec_config=self.exec_config, restrict_to_workspace=restrict_to_workspace, ) @@ -120,8 +123,8 @@ class AgentLoop: restrict_to_workspace=self.restrict_to_workspace, path_append=self.exec_config.path_append, )) - self.tools.register(WebSearchTool(api_key=self.brave_api_key)) - self.tools.register(WebFetchTool()) + self.tools.register(WebSearchTool(api_key=self.brave_api_key, proxy=self.web_proxy)) + self.tools.register(WebFetchTool(proxy=self.web_proxy)) self.tools.register(MessageTool(send_callback=self.bus.publish_outbound)) self.tools.register(SpawnTool(manager=self.subagents)) if self.cron_service: diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 9b543dc40..f2d6ee5f2 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -31,6 +31,7 @@ class SubagentManager: max_tokens: int = 4096, reasoning_effort: str | None = None, brave_api_key: str | None = None, + web_proxy: str | None = None, exec_config: "ExecToolConfig | None" = None, restrict_to_workspace: bool = False, ): @@ -43,6 +44,7 @@ class SubagentManager: self.max_tokens = max_tokens self.reasoning_effort = reasoning_effort self.brave_api_key = brave_api_key + self.web_proxy = web_proxy self.exec_config = exec_config or ExecToolConfig() self.restrict_to_workspace = restrict_to_workspace self._running_tasks: dict[str, asyncio.Task[None]] = {} @@ -104,8 +106,8 @@ class SubagentManager: restrict_to_workspace=self.restrict_to_workspace, path_append=self.exec_config.path_append, )) - tools.register(WebSearchTool(api_key=self.brave_api_key)) - tools.register(WebFetchTool()) + tools.register(WebSearchTool(api_key=self.brave_api_key, proxy=self.web_proxy)) + tools.register(WebFetchTool(proxy=self.web_proxy)) system_prompt = self._build_subagent_prompt() messages: list[dict[str, Any]] = [ diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index e817a4cea..0d2135d98 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -8,6 +8,7 @@ from typing import Any from urllib.parse import urlparse import httpx +from loguru import logger from nanobot.agent.tools.base import Tool @@ -57,9 +58,10 @@ class WebSearchTool(Tool): "required": ["query"] } - def __init__(self, api_key: str | None = None, max_results: int = 5): + def __init__(self, api_key: str | None = None, max_results: int = 5, proxy: str | None = None): self._init_api_key = api_key self.max_results = max_results + self.proxy = proxy @property def api_key(self) -> str: @@ -71,12 +73,16 @@ class WebSearchTool(Tool): return ( "Error: Brave Search API key not configured. " "Set it in ~/.nanobot/config.json under tools.web.search.apiKey " - "(or export BRAVE_API_KEY), then restart the gateway." + "(or export BRAIVE_API_KEY), then restart the gateway." ) try: n = min(max(count or self.max_results, 1), 10) - async with httpx.AsyncClient() as client: + if self.proxy: + logger.info("WebSearch: using proxy {} for query: {}", self.proxy, query[:50]) + else: + logger.debug("WebSearch: direct connection for query: {}", query[:50]) + async with httpx.AsyncClient(proxy=self.proxy) as client: r = await client.get( "https://api.search.brave.com/res/v1/web/search", params={"q": query, "count": n}, @@ -95,7 +101,11 @@ class WebSearchTool(Tool): if desc := item.get("description"): lines.append(f" {desc}") return "\n".join(lines) + except httpx.ProxyError as e: + logger.error("WebSearch proxy error: {}", e) + return f"Proxy error: {e}" except Exception as e: + logger.error("WebSearch error: {}", e) return f"Error: {e}" @@ -114,8 +124,9 @@ class WebFetchTool(Tool): "required": ["url"] } - def __init__(self, max_chars: int = 50000): + def __init__(self, max_chars: int = 50000, proxy: str | None = None): self.max_chars = max_chars + self.proxy = proxy async def execute(self, url: str, extractMode: str = "markdown", maxChars: int | None = None, **kwargs: Any) -> str: from readability import Document @@ -128,10 +139,15 @@ class WebFetchTool(Tool): return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url}, ensure_ascii=False) try: + if self.proxy: + logger.info("WebFetch: using proxy {} for {}", self.proxy, url) + else: + logger.debug("WebFetch: direct connection for {}", url) async with httpx.AsyncClient( follow_redirects=True, max_redirects=MAX_REDIRECTS, - timeout=30.0 + timeout=30.0, + proxy=self.proxy, ) as client: r = await client.get(url, headers={"User-Agent": USER_AGENT}) r.raise_for_status() @@ -156,7 +172,11 @@ class WebFetchTool(Tool): return json.dumps({"url": url, "finalUrl": str(r.url), "status": r.status_code, "extractor": extractor, "truncated": truncated, "length": len(text), "text": text}, ensure_ascii=False) + except httpx.ProxyError as e: + logger.error("WebFetch proxy error for {}: {}", url, e) + return json.dumps({"error": f"Proxy error: {e}", "url": url}, ensure_ascii=False) except Exception as e: + logger.error("WebFetch error for {}: {}", url, e) return json.dumps({"error": str(e), "url": url}, ensure_ascii=False) def _to_markdown(self, html: str) -> str: diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 4987c84bf..25fa8e106 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -284,6 +284,7 @@ def gateway( memory_window=config.agents.defaults.memory_window, reasoning_effort=config.agents.defaults.reasoning_effort, brave_api_key=config.tools.web.search.api_key or None, + web_proxy=config.tools.web.proxy or None, exec_config=config.tools.exec, cron_service=cron, restrict_to_workspace=config.tools.restrict_to_workspace, @@ -444,6 +445,7 @@ def agent( memory_window=config.agents.defaults.memory_window, reasoning_effort=config.agents.defaults.reasoning_effort, brave_api_key=config.tools.web.search.api_key or None, + web_proxy=config.tools.web.proxy or None, exec_config=config.tools.exec, cron_service=cron, restrict_to_workspace=config.tools.restrict_to_workspace, @@ -938,6 +940,7 @@ def cron_run( memory_window=config.agents.defaults.memory_window, reasoning_effort=config.agents.defaults.reasoning_effort, brave_api_key=config.tools.web.search.api_key or None, + web_proxy=config.tools.web.proxy or None, exec_config=config.tools.exec, restrict_to_workspace=config.tools.restrict_to_workspace, mcp_servers=config.tools.mcp_servers, diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 091a210ce..6b80c8180 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -290,6 +290,7 @@ class WebSearchConfig(Base): class WebToolsConfig(Base): """Web tools configuration.""" + proxy: str | None = None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" search: WebSearchConfig = Field(default_factory=WebSearchConfig) From 468dfc406bfdd96eb6049852f679336f8d13bbf2 Mon Sep 17 00:00:00 2001 From: VITOHJL Date: Sun, 1 Mar 2026 17:05:04 +0800 Subject: [PATCH 23/84] feat(cron): improve cron job context handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improve cron job execution context to ensure proper message delivery and session history recording. Changes: - Add [็ปฏ่ค็ฒบ็€นๆฐญๆค‚ๆต ่ฏฒๅงŸ] prefix to cron reminder messages to clearly mark them as system-driven, not user queries - Use user role for cron reminder messages (required by some LLM APIs) - Properly handle MessageTool to avoid duplicate message delivery - Correctly save turn history with proper skip count - Ensure Runtime Context is included in the message list This ensures that: 1. Cron jobs execute with proper context 2. Messages are correctly delivered to users 3. Session history accurately records cron job interactions 4. The LLM understands these are system-driven reminders, not user queries --- nanobot/cli/commands.py | 51 ++++++++++++++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 4987c84bf..4b70f32f1 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -295,20 +295,55 @@ def gateway( # Set cron callback (needs agent) async def on_cron_job(job: CronJob) -> str | None: """Execute a cron job through the agent.""" - response = await agent.process_direct( - job.payload.message, - session_key=f"cron:{job.id}", - channel=job.payload.channel or "cli", - chat_id=job.payload.to or "direct", + from nanobot.agent.tools.message import MessageTool + + cron_session_key = f"cron:{job.id}" + cron_session = agent.sessions.get_or_create(cron_session_key) + + reminder_note = ( + f"[็ณป็ปŸๅฎšๆ—ถไปปๅŠก] โฐ ่ฎกๆ—ถๅทฒ็ป“ๆŸ\n\n" + f"ๅฎšๆ—ถไปปๅŠก '{job.name}' ๅทฒ่งฆๅ‘ใ€‚ๅฎšๆ—ถๅ†…ๅฎน๏ผš{job.payload.message}\n\n" ) - if job.payload.deliver and job.payload.to: + + cron_session.add_message(role="user", content=reminder_note) + agent.sessions.save(cron_session) + + agent._set_tool_context( + job.payload.channel or "cli", + job.payload.to or "direct", + None + ) + + message_tool = agent.tools.get("message") + if isinstance(message_tool, MessageTool): + message_tool.start_turn() + + history = cron_session.get_history(max_messages=agent.memory_window) + + messages = [ + {"role": "system", "content": agent.context.build_system_prompt()}, + *history, + {"role": "user", "content": agent.context._build_runtime_context( + job.payload.channel or "cli", + job.payload.to or "direct" + )}, + ] + + final_content, _, all_msgs = await agent._run_agent_loop(messages) + agent._save_turn(cron_session, all_msgs, 1 + len(history) + 1) + agent.sessions.save(cron_session) + + if isinstance(message_tool, MessageTool) and message_tool._sent_in_turn: + return final_content + + if job.payload.deliver and job.payload.to and final_content: from nanobot.bus.events import OutboundMessage await bus.publish_outbound(OutboundMessage( channel=job.payload.channel or "cli", chat_id=job.payload.to, - content=response or "" + content=final_content )) - return response + return final_content cron.on_job = on_cron_job # Create channel manager From a7d24192d94c413c035284e7f1905666c63bed80 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 1 Mar 2026 12:45:53 +0000 Subject: [PATCH 24/84] fix(cron): route scheduled jobs through process_direct with english reminder prefix --- nanobot/cli/commands.py | 53 ++++++++++++----------------------------- 1 file changed, 15 insertions(+), 38 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 4b70f32f1..fbc8e2069 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -296,54 +296,31 @@ def gateway( async def on_cron_job(job: CronJob) -> str | None: """Execute a cron job through the agent.""" from nanobot.agent.tools.message import MessageTool - - cron_session_key = f"cron:{job.id}" - cron_session = agent.sessions.get_or_create(cron_session_key) - reminder_note = ( - f"[็ณป็ปŸๅฎšๆ—ถไปปๅŠก] โฐ ่ฎกๆ—ถๅทฒ็ป“ๆŸ\n\n" - f"ๅฎšๆ—ถไปปๅŠก '{job.name}' ๅทฒ่งฆๅ‘ใ€‚ๅฎšๆ—ถๅ†…ๅฎน๏ผš{job.payload.message}\n\n" + "[Scheduled Task] Timer finished.\n\n" + f"Task '{job.name}' has been triggered.\n" + f"Scheduled instruction: {job.payload.message}" ) - - cron_session.add_message(role="user", content=reminder_note) - agent.sessions.save(cron_session) - - agent._set_tool_context( - job.payload.channel or "cli", - job.payload.to or "direct", - None + + response = await agent.process_direct( + reminder_note, + session_key=f"cron:{job.id}", + channel=job.payload.channel or "cli", + chat_id=job.payload.to or "direct", ) - + message_tool = agent.tools.get("message") - if isinstance(message_tool, MessageTool): - message_tool.start_turn() - - history = cron_session.get_history(max_messages=agent.memory_window) - - messages = [ - {"role": "system", "content": agent.context.build_system_prompt()}, - *history, - {"role": "user", "content": agent.context._build_runtime_context( - job.payload.channel or "cli", - job.payload.to or "direct" - )}, - ] - - final_content, _, all_msgs = await agent._run_agent_loop(messages) - agent._save_turn(cron_session, all_msgs, 1 + len(history) + 1) - agent.sessions.save(cron_session) - if isinstance(message_tool, MessageTool) and message_tool._sent_in_turn: - return final_content - - if job.payload.deliver and job.payload.to and final_content: + return response + + if job.payload.deliver and job.payload.to and response: from nanobot.bus.events import OutboundMessage await bus.publish_outbound(OutboundMessage( channel=job.payload.channel or "cli", chat_id=job.payload.to, - content=final_content + content=response )) - return final_content + return response cron.on_job = on_cron_job # Create channel manager From 15529c668e51c623ab860509b12346e3dfe956d6 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 1 Mar 2026 12:53:18 +0000 Subject: [PATCH 25/84] fix(web): sanitize proxy logs and polish search key hint --- nanobot/agent/tools/web.py | 27 ++++++++------------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index 0d2135d98..0d8f4d167 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -71,17 +71,14 @@ class WebSearchTool(Tool): async def execute(self, query: str, count: int | None = None, **kwargs: Any) -> str: if not self.api_key: return ( - "Error: Brave Search API key not configured. " - "Set it in ~/.nanobot/config.json under tools.web.search.apiKey " - "(or export BRAIVE_API_KEY), then restart the gateway." + "Error: Brave Search API key not configured. Set it in " + "~/.nanobot/config.json under tools.web.search.apiKey " + "(or export BRAVE_API_KEY), then restart the gateway." ) try: n = min(max(count or self.max_results, 1), 10) - if self.proxy: - logger.info("WebSearch: using proxy {} for query: {}", self.proxy, query[:50]) - else: - logger.debug("WebSearch: direct connection for query: {}", query[:50]) + logger.debug("WebSearch: {}", "proxy enabled" if self.proxy else "direct connection") async with httpx.AsyncClient(proxy=self.proxy) as client: r = await client.get( "https://api.search.brave.com/res/v1/web/search", @@ -91,12 +88,12 @@ class WebSearchTool(Tool): ) r.raise_for_status() - results = r.json().get("web", {}).get("results", []) + results = r.json().get("web", {}).get("results", [])[:n] if not results: return f"No results for: {query}" lines = [f"Results for: {query}\n"] - for i, item in enumerate(results[:n], 1): + for i, item in enumerate(results, 1): lines.append(f"{i}. {item.get('title', '')}\n {item.get('url', '')}") if desc := item.get("description"): lines.append(f" {desc}") @@ -132,17 +129,12 @@ class WebFetchTool(Tool): from readability import Document max_chars = maxChars or self.max_chars - - # Validate URL before fetching is_valid, error_msg = _validate_url(url) if not is_valid: return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url}, ensure_ascii=False) try: - if self.proxy: - logger.info("WebFetch: using proxy {} for {}", self.proxy, url) - else: - logger.debug("WebFetch: direct connection for {}", url) + logger.debug("WebFetch: {}", "proxy enabled" if self.proxy else "direct connection") async with httpx.AsyncClient( follow_redirects=True, max_redirects=MAX_REDIRECTS, @@ -154,10 +146,8 @@ class WebFetchTool(Tool): ctype = r.headers.get("content-type", "") - # JSON if "application/json" in ctype: text, extractor = json.dumps(r.json(), indent=2, ensure_ascii=False), "json" - # HTML elif "text/html" in ctype or r.text[:256].lower().startswith((" max_chars - if truncated: - text = text[:max_chars] + if truncated: text = text[:max_chars] return json.dumps({"url": url, "finalUrl": str(r.url), "status": r.status_code, "extractor": extractor, "truncated": truncated, "length": len(text), "text": text}, ensure_ascii=False) From 8571df2e634809b396b08b0967957078c15ede7f Mon Sep 17 00:00:00 2001 From: zerone0x Date: Sun, 1 Mar 2026 15:13:44 +0100 Subject: [PATCH 26/84] fix(feishu): split card messages when content has multiple tables Feishu rejects interactive cards that contain more than one table element (API error 11310: card table number over limit). Add FeishuChannel._split_elements_by_table_limit() which partitions the flat card-elements list into groups of at most one table each. The send() method now iterates over these groups and sends each as its own card message, so all tables are delivered to the user instead of the entire message being dropped. Single-table and table-free messages are unaffected (one card, same as before). Fixes #1382 --- nanobot/channels/feishu.py | 40 ++++++++++-- tests/test_feishu_table_split.py | 104 +++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 5 deletions(-) create mode 100644 tests/test_feishu_table_split.py diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 0a0a5e492..9ab1d503b 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -413,6 +413,34 @@ class FeishuChannel(BaseChannel): elements.extend(self._split_headings(remaining)) return elements or [{"tag": "markdown", "content": content}] + @staticmethod + def _split_elements_by_table_limit(elements: list[dict], max_tables: int = 1) -> list[list[dict]]: + """Split card elements into groups with at most *max_tables* table elements each. + + Feishu cards have a hard limit of one table per card (API error 11310). + When the rendered content contains multiple markdown tables each table is + placed in a separate card message so every table reaches the user. + """ + if not elements: + return [[]] + groups: list[list[dict]] = [] + current: list[dict] = [] + table_count = 0 + for el in elements: + if el.get("tag") == "table": + if table_count >= max_tables: + if current: + groups.append(current) + current = [] + table_count = 0 + current.append(el) + table_count += 1 + else: + current.append(el) + if current: + groups.append(current) + return groups or [[]] + def _split_headings(self, content: str) -> list[dict]: """Split content by headings, converting headings to div elements.""" protected = content @@ -653,11 +681,13 @@ class FeishuChannel(BaseChannel): ) if msg.content and msg.content.strip(): - card = {"config": {"wide_screen_mode": True}, "elements": self._build_card_elements(msg.content)} - await loop.run_in_executor( - None, self._send_message_sync, - receive_id_type, msg.chat_id, "interactive", json.dumps(card, ensure_ascii=False), - ) + elements = self._build_card_elements(msg.content) + for chunk in self._split_elements_by_table_limit(elements): + card = {"config": {"wide_screen_mode": True}, "elements": chunk} + await loop.run_in_executor( + None, self._send_message_sync, + receive_id_type, msg.chat_id, "interactive", json.dumps(card, ensure_ascii=False), + ) except Exception as e: logger.error("Error sending Feishu message: {}", e) diff --git a/tests/test_feishu_table_split.py b/tests/test_feishu_table_split.py new file mode 100644 index 000000000..af8fa164a --- /dev/null +++ b/tests/test_feishu_table_split.py @@ -0,0 +1,104 @@ +"""Tests for FeishuChannel._split_elements_by_table_limit. + +Feishu cards reject messages that contain more than one table element +(API error 11310: card table number over limit). The helper splits a flat +list of card elements into groups so that each group contains at most one +table, allowing nanobot to send multiple cards instead of failing. +""" + +from nanobot.channels.feishu import FeishuChannel + + +def _md(text: str) -> dict: + return {"tag": "markdown", "content": text} + + +def _table() -> dict: + return { + "tag": "table", + "columns": [{"tag": "column", "name": "c0", "display_name": "A", "width": "auto"}], + "rows": [{"c0": "v"}], + "page_size": 2, + } + + +split = FeishuChannel._split_elements_by_table_limit + + +def test_empty_list_returns_single_empty_group() -> None: + assert split([]) == [[]] + + +def test_no_tables_returns_single_group() -> None: + els = [_md("hello"), _md("world")] + result = split(els) + assert result == [els] + + +def test_single_table_stays_in_one_group() -> None: + els = [_md("intro"), _table(), _md("outro")] + result = split(els) + assert len(result) == 1 + assert result[0] == els + + +def test_two_tables_split_into_two_groups() -> None: + # Use different row values so the two tables are not equal + t1 = { + "tag": "table", + "columns": [{"tag": "column", "name": "c0", "display_name": "A", "width": "auto"}], + "rows": [{"c0": "table-one"}], + "page_size": 2, + } + t2 = { + "tag": "table", + "columns": [{"tag": "column", "name": "c0", "display_name": "B", "width": "auto"}], + "rows": [{"c0": "table-two"}], + "page_size": 2, + } + els = [_md("before"), t1, _md("between"), t2, _md("after")] + result = split(els) + assert len(result) == 2 + # First group: text before table-1 + table-1 + assert t1 in result[0] + assert t2 not in result[0] + # Second group: text between tables + table-2 + text after + assert t2 in result[1] + assert t1 not in result[1] + + +def test_three_tables_split_into_three_groups() -> None: + tables = [ + {"tag": "table", "columns": [], "rows": [{"c0": f"t{i}"}], "page_size": 1} + for i in range(3) + ] + els = tables[:] + result = split(els) + assert len(result) == 3 + for i, group in enumerate(result): + assert tables[i] in group + + +def test_leading_markdown_stays_with_first_table() -> None: + intro = _md("intro") + t = _table() + result = split([intro, t]) + assert len(result) == 1 + assert result[0] == [intro, t] + + +def test_trailing_markdown_after_second_table() -> None: + t1, t2 = _table(), _table() + tail = _md("end") + result = split([t1, t2, tail]) + assert len(result) == 2 + assert result[1] == [t2, tail] + + +def test_non_table_elements_before_first_table_kept_in_first_group() -> None: + head = _md("head") + t1, t2 = _table(), _table() + result = split([head, t1, t2]) + # head + t1 in group 0; t2 in group 1 + assert result[0] == [head, t1] + assert result[1] == [t2] From ae788a17f8371de0d60b7b9d713bdb8261fa6cd2 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Mon, 2 Mar 2026 11:03:54 +0800 Subject: [PATCH 27/84] chore: add .worktrees to .gitignore Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d7b930d41..a543251a7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.worktrees/ .assets .env *.pyc From aed1ef55298433a963474d8fbdcf0b203945ffb5 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Mon, 2 Mar 2026 11:04:53 +0800 Subject: [PATCH 28/84] fix: add SIGTERM, SIGHUP handling and ignore SIGPIPE - Add handler for SIGTERM to prevent "Terminated" message on Linux - Add handler for SIGHUP for terminal closure handling - Ignore SIGPIPE to prevent silent process termination - Change os._exit(0) to sys.exit(0) for proper cleanup Fixes issue #1365 Co-Authored-By: Claude Opus 4.6 --- nanobot/cli/commands.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 2662e9fe7..8c53992b6 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -501,12 +501,17 @@ def agent( else: cli_channel, cli_chat_id = "cli", session_id - def _exit_on_sigint(signum, frame): + def _handle_signal(signum, frame): + sig_name = signal.Signals(signum).name _restore_terminal() - console.print("\nGoodbye!") - os._exit(0) + console.print(f"\nReceived {sig_name}, goodbye!") + sys.exit(0) - signal.signal(signal.SIGINT, _exit_on_sigint) + signal.signal(signal.SIGINT, _handle_signal) + signal.signal(signal.SIGTERM, _handle_signal) + signal.signal(signal.SIGHUP, _handle_signal) + # Ignore SIGPIPE to prevent silent process termination when writing to closed pipes + signal.signal(signal.SIGPIPE, signal.SIG_IGN) async def run_interactive(): bus_task = asyncio.create_task(agent_loop.run()) From dba93ae83afe0a91a7fd6a79f40eb81ab30a5e14 Mon Sep 17 00:00:00 2001 From: yzchen Date: Mon, 2 Mar 2026 11:19:45 +0800 Subject: [PATCH 29/84] cron: reload jobs store on each timer tick --- nanobot/cron/service.py | 2 ++ tests/test_cron_service.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index c3864aeb9..811dc3ba8 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -226,6 +226,8 @@ class CronService: async def _on_timer(self) -> None: """Handle timer tick - run due jobs.""" + # Pick up external CLI/file changes before deciding due jobs. + self._load_store() if not self._store: return diff --git a/tests/test_cron_service.py b/tests/test_cron_service.py index 07e990a0d..2a36f4c39 100644 --- a/tests/test_cron_service.py +++ b/tests/test_cron_service.py @@ -1,3 +1,5 @@ +import asyncio + import pytest from nanobot.cron.service import CronService @@ -28,3 +30,30 @@ def test_add_job_accepts_valid_timezone(tmp_path) -> None: assert job.schedule.tz == "America/Vancouver" assert job.state.next_run_at_ms is not None + + +@pytest.mark.asyncio +async def test_running_service_honors_external_disable(tmp_path) -> None: + store_path = tmp_path / "cron" / "jobs.json" + called: list[str] = [] + + async def on_job(job) -> None: + called.append(job.id) + + service = CronService(store_path, on_job=on_job) + job = service.add_job( + name="external-disable", + schedule=CronSchedule(kind="every", every_ms=200), + message="hello", + ) + await service.start() + try: + external = CronService(store_path) + updated = external.enable_job(job.id, enabled=False) + assert updated is not None + assert updated.enabled is False + + await asyncio.sleep(0.35) + assert called == [] + finally: + service.stop() From e9d023f52cbc7fb8eab37ab5aa4a501b0b5bdc81 Mon Sep 17 00:00:00 2001 From: Joel Chan Date: Thu, 12 Feb 2026 17:10:50 +0800 Subject: [PATCH 30/84] feat(discord): add group policy to control group respond behaviour --- README.md | 8 +++++++- nanobot/channels/discord.py | 36 +++++++++++++++++++++++++++++++++++- nanobot/config/schema.py | 1 + 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 45779e727..f141a1cc8 100644 --- a/README.md +++ b/README.md @@ -293,12 +293,18 @@ If you prefer to configure manually, add the following to `~/.nanobot/config.jso "discord": { "enabled": true, "token": "YOUR_BOT_TOKEN", - "allowFrom": ["YOUR_USER_ID"] + "allowFrom": ["YOUR_USER_ID"], + "groupPolicy": "mention" } } } ``` +> `groupPolicy` controls how the bot responds in group channels: +> - `"mention"` (default) โ€” Only respond when @mentioned +> - `"open"` โ€” Respond to all messages +> DMs always respond when the sender is in `allowFrom`. + **5. Invite the bot** - OAuth2 โ†’ URL Generator - Scopes: `bot` diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index 57e59220c..85ff28a3f 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -54,6 +54,7 @@ class DiscordChannel(BaseChannel): self._heartbeat_task: asyncio.Task | None = None self._typing_tasks: dict[str, asyncio.Task] = {} self._http: httpx.AsyncClient | None = None + self._bot_user_id: str | None = None async def start(self) -> None: """Start the Discord gateway connection.""" @@ -170,6 +171,10 @@ class DiscordChannel(BaseChannel): await self._identify() elif op == 0 and event_type == "READY": logger.info("Discord gateway READY") + # Capture bot user ID for mention detection + user_data = payload.get("user") or {} + self._bot_user_id = user_data.get("id") + logger.info(f"Discord bot connected as user {self._bot_user_id}") elif op == 0 and event_type == "MESSAGE_CREATE": await self._handle_message_create(payload) elif op == 7: @@ -226,6 +231,7 @@ class DiscordChannel(BaseChannel): sender_id = str(author.get("id", "")) channel_id = str(payload.get("channel_id", "")) content = payload.get("content") or "" + guild_id = payload.get("guild_id") if not sender_id or not channel_id: return @@ -233,6 +239,11 @@ class DiscordChannel(BaseChannel): if not self.is_allowed(sender_id): return + # Check group channel policy (DMs always respond if is_allowed passes) + if guild_id is not None: + if not self._should_respond_in_group(payload, content): + return + content_parts = [content] if content else [] media_paths: list[str] = [] media_dir = Path.home() / ".nanobot" / "media" @@ -269,11 +280,34 @@ class DiscordChannel(BaseChannel): media=media_paths, metadata={ "message_id": str(payload.get("id", "")), - "guild_id": payload.get("guild_id"), + "guild_id": guild_id, "reply_to": reply_to, }, ) + def _should_respond_in_group(self, payload: dict[str, Any], content: str) -> bool: + """Check if bot should respond in a group channel based on policy.""" + channel_id = str(payload.get("channel_id", "")) + + if self.config.group_policy == "open": + return True + + if self.config.group_policy == "mention": + # Check if bot was mentioned in the message + if self._bot_user_id: + # Check mentions array + mentions = payload.get("mentions") or [] + for mention in mentions: + if str(mention.get("id")) == self._bot_user_id: + return True + # Also check content for mention format <@USER_ID> + if f"<@{self._bot_user_id}>" in content or f"<@!{self._bot_user_id}>" in content: + return True + logger.debug(f"Discord message in {channel_id} ignored (bot not mentioned)") + return False + + return True + async def _start_typing(self, channel_id: str) -> None: """Start periodic typing indicator for a channel.""" await self._stop_typing(channel_id) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 6b80c8180..e3d3d23f1 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -62,6 +62,7 @@ class DiscordConfig(Base): allow_from: list[str] = Field(default_factory=list) # Allowed user IDs gateway_url: str = "wss://gateway.discord.gg/?v=10&encoding=json" intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT + group_policy: str = "open" # "mention" or "open" class MatrixConfig(Base): From d447be5ca22c335945c51d15d119562af82b27e8 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Mon, 2 Mar 2026 13:17:39 +0800 Subject: [PATCH 31/84] security: deny by default in is_allowed for all channels When allow_from is not configured, block all access by default instead of allowing everyone. This prevents unauthorized access when channels are enabled without explicit allow lists. --- nanobot/channels/base.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index f7959312d..d73d34c84 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -70,9 +70,16 @@ class BaseChannel(ABC): """ allow_list = getattr(self.config, "allow_from", []) - # If no allow list, allow everyone + # Security fix: If no allow list, deny everyone by default + # This prevents unauthorized access when allow_from is not configured if not allow_list: - return True + logger.warning( + "Channel {} has no allow_from configured - " + "blocking all access by default for security. " + "Add allowed senders to config to enable access.", + self.name, + ) + return False sender_str = str(sender_id) if sender_str in allow_list: From 2c63946519eb7ba63d4c6613510dd1d50bda9353 Mon Sep 17 00:00:00 2001 From: Wenjie Lei Date: Sun, 1 Mar 2026 21:56:08 -0800 Subject: [PATCH 32/84] fix(matrix): normalize media metadata and keyword-call attachment upload --- nanobot/channels/matrix.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 43fc57317..c6b1f9165 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -362,7 +362,11 @@ class MatrixChannel(BaseChannel): limit_bytes = await self._effective_media_limit_bytes() for path in candidates: if fail := await self._upload_and_send_attachment( - msg.chat_id, path, limit_bytes, relates_to): + room_id=msg.chat_id, + path=path, + limit_bytes=limit_bytes, + relates_to=relates_to, + ): failures.append(fail) if failures: text = f"{text.rstrip()}\n{chr(10).join(failures)}" if text.strip() else "\n".join(failures) @@ -676,11 +680,13 @@ class MatrixChannel(BaseChannel): parts: list[str] = [] if isinstance(body := getattr(event, "body", None), str) and body.strip(): parts.append(body.strip()) - parts.append(marker) + if marker: + parts.append(marker) await self._start_typing_keepalive(room.room_id) try: meta = self._base_metadata(room, event) + meta["attachments"] = [] if attachment: meta["attachments"] = [attachment] await self._handle_message( From bbfc1b40c1251814e70a55cc947b48375c3bbc71 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 2 Mar 2026 06:13:37 +0000 Subject: [PATCH 33/84] security: deny-by-default allowFrom with wildcard support and startup validation --- README.md | 18 ++++++++++-------- SECURITY.md | 5 ++--- nanobot/channels/base.py | 33 +++++++-------------------------- nanobot/channels/manager.py | 10 ++++++++++ nanobot/channels/matrix.py | 3 +-- nanobot/config/schema.py | 1 + 6 files changed, 31 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 45779e727..01da22899 100644 --- a/README.md +++ b/README.md @@ -347,7 +347,7 @@ pip install nanobot-ai[matrix] "accessToken": "syt_xxx", "deviceId": "NANOBOT01", "e2eeEnabled": true, - "allowFrom": [], + "allowFrom": ["@your_user:matrix.org"], "groupPolicy": "open", "groupAllowFrom": [], "allowRoomMentions": false, @@ -441,14 +441,14 @@ Uses **WebSocket** long connection โ€” no public IP required. "appSecret": "xxx", "encryptKey": "", "verificationToken": "", - "allowFrom": [] + "allowFrom": ["ou_YOUR_OPEN_ID"] } } } ``` > `encryptKey` and `verificationToken` are optional for Long Connection mode. -> `allowFrom`: Leave empty to allow all users, or add `["ou_xxx"]` to restrict access. +> `allowFrom`: Add your open_id (find it in nanobot logs when you message the bot). Use `["*"]` to allow all users. **3. Run** @@ -478,7 +478,7 @@ Uses **botpy SDK** with WebSocket โ€” no public IP required. Currently supports **3. Configure** -> - `allowFrom`: Leave empty for public access, or add user openids to restrict. You can find openids in the nanobot logs when a user messages the bot. +> - `allowFrom`: Add your openid (find it in nanobot logs when you message the bot). Use `["*"]` for public access. > - For production: submit a review in the bot console and publish. See [QQ Bot Docs](https://bot.q.qq.com/wiki/) for the full publishing flow. ```json @@ -488,7 +488,7 @@ Uses **botpy SDK** with WebSocket โ€” no public IP required. Currently supports "enabled": true, "appId": "YOUR_APP_ID", "secret": "YOUR_APP_SECRET", - "allowFrom": [] + "allowFrom": ["YOUR_OPENID"] } } } @@ -527,13 +527,13 @@ Uses **Stream Mode** โ€” no public IP required. "enabled": true, "clientId": "YOUR_APP_KEY", "clientSecret": "YOUR_APP_SECRET", - "allowFrom": [] + "allowFrom": ["YOUR_STAFF_ID"] } } } ``` -> `allowFrom`: Leave empty to allow all users, or add `["staffId"]` to restrict access. +> `allowFrom`: Add your staff ID. Use `["*"]` to allow all users. **3. Run** @@ -568,6 +568,7 @@ Uses **Socket Mode** โ€” no public URL required. "enabled": true, "botToken": "xoxb-...", "appToken": "xapp-...", + "allowFrom": ["YOUR_SLACK_USER_ID"], "groupPolicy": "mention" } } @@ -601,7 +602,7 @@ Give nanobot its own email account. It polls **IMAP** for incoming mail and repl **2. Configure** > - `consentGranted` must be `true` to allow mailbox access. This is a safety gate โ€” set `false` to fully disable. -> - `allowFrom`: Leave empty to accept emails from anyone, or restrict to specific senders. +> - `allowFrom`: Add your email address. Use `["*"]` to accept emails from anyone. > - `smtpUseTls` and `smtpUseSsl` default to `true` / `false` respectively, which is correct for Gmail (port 587 + STARTTLS). No need to set them explicitly. > - Set `"autoReplyEnabled": false` if you only want to read/analyze emails without sending automatic replies. @@ -874,6 +875,7 @@ MCP tools are automatically discovered and registered on startup. The LLM can us > [!TIP] > For production deployments, set `"restrictToWorkspace": true` in your config to sandbox the agent. +> **Change in source / post-`v0.1.4.post3`:** In `v0.1.4.post3` and earlier, an empty `allowFrom` means "allow all senders". In newer versions (including building from source), **empty `allowFrom` denies all access by default**. To allow all senders, set `"allowFrom": ["*"]`. | Option | Default | Description | |--------|---------|-------------| diff --git a/SECURITY.md b/SECURITY.md index 405ce5243..af4da713f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -55,7 +55,7 @@ chmod 600 ~/.nanobot/config.json ``` **Security Notes:** -- Empty `allowFrom` list will **ALLOW ALL** users (open by default for personal use) +- In `v0.1.4.post3` and earlier, an empty `allowFrom` allows all users. In newer versions (including source builds), **empty `allowFrom` denies all access** โ€” set `["*"]` to explicitly allow everyone. - Get your Telegram user ID from `@userinfobot` - Use full phone numbers with country code for WhatsApp - Review access logs regularly for unauthorized access attempts @@ -212,9 +212,8 @@ If you suspect a security breach: - Input length limits on HTTP requests โœ… **Authentication** -- Allow-list based access control +- Allow-list based access control โ€” in `v0.1.4.post3` and earlier empty means allow all; in newer versions empty means deny all (`["*"]` to explicitly allow all) - Failed authentication attempt logging -- Open by default (configure allowFrom for production use) โœ… **Resource Protection** - Command execution timeouts (60s default) diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index d73d34c84..b38fcaf28 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -59,36 +59,17 @@ class BaseChannel(ABC): pass def is_allowed(self, sender_id: str) -> bool: - """ - Check if a sender is allowed to use this bot. - - Args: - sender_id: The sender's identifier. - - Returns: - True if allowed, False otherwise. - """ + """Check if *sender_id* is permitted. Empty list โ†’ deny all; ``"*"`` โ†’ allow all.""" allow_list = getattr(self.config, "allow_from", []) - - # Security fix: If no allow list, deny everyone by default - # This prevents unauthorized access when allow_from is not configured if not allow_list: - logger.warning( - "Channel {} has no allow_from configured - " - "blocking all access by default for security. " - "Add allowed senders to config to enable access.", - self.name, - ) + logger.warning("{}: allow_from is empty โ€” all access denied", self.name) return False - - sender_str = str(sender_id) - if sender_str in allow_list: + if "*" in allow_list: return True - if "|" in sender_str: - for part in sender_str.split("|"): - if part and part in allow_list: - return True - return False + sender_str = str(sender_id) + return sender_str in allow_list or any( + p in allow_list for p in sender_str.split("|") if p + ) async def _handle_message( self, diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 4b40d0ee5..7d7d11065 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -149,6 +149,16 @@ class ChannelManager: except ImportError as e: logger.warning("Matrix channel not available: {}", e) + self._validate_allow_from() + + def _validate_allow_from(self) -> None: + for name, ch in self.channels.items(): + if getattr(ch.config, "allow_from", None) == []: + raise SystemExit( + f'Error: "{name}" has empty allowFrom (denies all). ' + f'Set ["*"] to allow everyone, or add specific user IDs.' + ) + async def _start_channel(self, name: str, channel: BaseChannel) -> None: """Start a channel and log any exceptions.""" try: diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 43fc57317..b19975c22 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -450,8 +450,7 @@ class MatrixChannel(BaseChannel): await asyncio.sleep(2) async def _on_room_invite(self, room: MatrixRoom, event: InviteEvent) -> None: - allow_from = self.config.allow_from or [] - if not allow_from or event.sender in allow_from: + if self.is_allowed(event.sender): await self.client.join(room.room_id) def _is_direct_room(self, room: MatrixRoom) -> bool: diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 6b80c8180..61a7bd242 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -171,6 +171,7 @@ class SlackConfig(Base): user_token_read_only: bool = True reply_in_thread: bool = True react_emoji: str = "eyes" + allow_from: list[str] = Field(default_factory=list) # Allowed Slack user IDs (sender-level) group_policy: str = "mention" # "mention", "open", "allowlist" group_allow_from: list[str] = Field(default_factory=list) # Allowed channel IDs if allowlist dm: SlackDMConfig = Field(default_factory=SlackDMConfig) From 9877195de57817101485f3effe7780f81c86f2d7 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 2 Mar 2026 06:37:57 +0000 Subject: [PATCH 34/84] chore(cron): remove redundant timer comment --- nanobot/cron/service.py | 1 - 1 file changed, 1 deletion(-) diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index 811dc3ba8..1ed71f0f4 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -226,7 +226,6 @@ class CronService: async def _on_timer(self) -> None: """Handle timer tick - run due jobs.""" - # Pick up external CLI/file changes before deciding due jobs. self._load_store() if not self._store: return From 3c79404194ad95702bc49a908ce984b6a26c26c9 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 2 Mar 2026 06:58:10 +0000 Subject: [PATCH 35/84] fix(providers): sanitize thinking_blocks by provider and harden content normalization --- nanobot/providers/base.py | 6 ++++++ nanobot/providers/litellm_provider.py | 22 ++++++++++++++++------ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index af23a4cfe..55bd80571 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -78,6 +78,12 @@ class LLMProvider(ABC): result.append(clean) continue + if isinstance(content, dict): + clean = dict(msg) + clean["content"] = [content] + result.append(clean) + continue + result.append(msg) return result diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 79277bc64..d8d8acef9 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -12,9 +12,9 @@ from litellm import acompletion from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest from nanobot.providers.registry import find_by_model, find_gateway -# Standard OpenAI chat-completion message keys plus reasoning_content for -# thinking-enabled models (Kimi k2.5, DeepSeek-R1, etc.). -_ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content", "thinking_blocks"}) +# Standard chat-completion message keys. +_ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content"}) +_ANTHROPIC_EXTRA_KEYS = frozenset({"thinking_blocks"}) _ALNUM = string.ascii_letters + string.digits def _short_tool_id() -> str: @@ -158,11 +158,20 @@ class LiteLLMProvider(LLMProvider): return @staticmethod - def _sanitize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + def _extra_msg_keys(original_model: str, resolved_model: str) -> frozenset[str]: + """Return provider-specific extra keys to preserve in request messages.""" + spec = find_by_model(original_model) or find_by_model(resolved_model) + if (spec and spec.name == "anthropic") or "claude" in original_model.lower() or resolved_model.startswith("anthropic/"): + return _ANTHROPIC_EXTRA_KEYS + return frozenset() + + @staticmethod + def _sanitize_messages(messages: list[dict[str, Any]], extra_keys: frozenset[str] = frozenset()) -> list[dict[str, Any]]: """Strip non-standard keys and ensure assistant messages have a content key.""" + allowed = _ALLOWED_MSG_KEYS | extra_keys sanitized = [] for msg in messages: - clean = {k: v for k, v in msg.items() if k in _ALLOWED_MSG_KEYS} + clean = {k: v for k, v in msg.items() if k in allowed} # Strict providers require "content" even when assistant only has tool_calls if clean.get("role") == "assistant" and "content" not in clean: clean["content"] = None @@ -193,6 +202,7 @@ class LiteLLMProvider(LLMProvider): """ original_model = model or self.default_model model = self._resolve_model(original_model) + extra_msg_keys = self._extra_msg_keys(original_model, model) if self._supports_cache_control(original_model): messages, tools = self._apply_cache_control(messages, tools) @@ -203,7 +213,7 @@ class LiteLLMProvider(LLMProvider): kwargs: dict[str, Any] = { "model": model, - "messages": self._sanitize_messages(self._sanitize_empty_content(messages)), + "messages": self._sanitize_messages(self._sanitize_empty_content(messages), extra_keys=extra_msg_keys), "max_tokens": max_tokens, "temperature": temperature, } From ecdfaf0a5a00b0772719aa306d4cb36d8512f9c7 Mon Sep 17 00:00:00 2001 From: David Markey Date: Sun, 1 Mar 2026 20:49:00 +0000 Subject: [PATCH 36/84] feat(custom-provider): add x-session-affinity header for prompt caching --- nanobot/providers/custom_provider.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/nanobot/providers/custom_provider.py b/nanobot/providers/custom_provider.py index 56e6270f9..02183f3d9 100644 --- a/nanobot/providers/custom_provider.py +++ b/nanobot/providers/custom_provider.py @@ -2,6 +2,7 @@ from __future__ import annotations +import uuid from typing import Any import json_repair @@ -15,7 +16,11 @@ class CustomProvider(LLMProvider): def __init__(self, api_key: str = "no-key", api_base: str = "http://localhost:8000/v1", default_model: str = "default"): super().__init__(api_key, api_base) self.default_model = default_model - self._client = AsyncOpenAI(api_key=api_key, base_url=api_base) + self._client = AsyncOpenAI( + api_key=api_key, + base_url=api_base, + default_headers={"x-session-affinity": uuid.uuid4().hex}, + ) async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None, model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7, From 8f4baaa5ce750fc073921fa29e734a0fe0da2056 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Mon, 2 Mar 2026 22:01:02 +0800 Subject: [PATCH 37/84] feat(gateway): support multiple instances with --workspace and --config options - Add --workspace/-w flag to specify workspace directory - Add --config/-c flag to specify config file path - Move cron store to workspace directory for per-instance isolation - Enable running multiple nanobot instances simultaneously --- README.md | 27 +++++++++++++++++++++++++++ nanobot/cli/commands.py | 13 ++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 01da22899..30227080c 100644 --- a/README.md +++ b/README.md @@ -884,6 +884,33 @@ MCP tools are automatically discovered and registered on startup. The LLM can us | `channels.*.allowFrom` | `[]` (allow all) | Whitelist of user IDs. Empty = allow everyone; non-empty = only listed users can interact. | +## Multiple Instances + +Run multiple nanobot instances simultaneously, each with its own workspace and configuration. + +```bash +# Instance A - Telegram bot +nanobot gateway -w ~/.nanobot/botA -p 18791 + +# Instance B - Discord bot +nanobot gateway -w ~/.nanobot/botB -p 18792 + +# Instance C - Using custom config file +nanobot gateway -w ~/.nanobot/botC -c ~/.nanobot/botC/config.json -p 18793 +``` + +| Option | Short | Description | +|--------|-------|-------------| +| `--workspace` | `-w` | Workspace directory (default: `~/.nanobot/workspace`) | +| `--config` | `-c` | Config file path (default: `~/.nanobot/config.json`) | +| `--port` | `-p` | Gateway port (default: `18790`) | + +Each instance has its own: +- Workspace directory (MEMORY.md, HEARTBEAT.md, session files) +- Cron jobs storage (`workspace/cron/jobs.json`) +- Configuration (if using `--config`) + + ## CLI Reference | Command | Description | diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 2662e9fe7..e599b1160 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -244,6 +244,8 @@ def _make_provider(config: Config): @app.command() def gateway( port: int = typer.Option(18790, "--port", "-p", help="Gateway port"), + workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory (default: ~/.nanobot/workspace)"), + config: str | None = typer.Option(None, "--config", "-c", help="Config file path"), verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"), ): """Start the nanobot gateway.""" @@ -260,6 +262,14 @@ def gateway( import logging logging.basicConfig(level=logging.DEBUG) + # Load config from custom path if provided, otherwise use default + config_path = Path(config) if config else None + config = load_config(config_path) + + # Override workspace if specified via command line + if workspace: + config.agents.defaults.workspace = workspace + console.print(f"{__logo__} Starting nanobot gateway on port {port}...") config = load_config() @@ -269,7 +279,8 @@ def gateway( session_manager = SessionManager(config.workspace_path) # Create cron service first (callback set after agent creation) - cron_store_path = get_data_dir() / "cron" / "jobs.json" + # Use workspace path for per-instance cron store + cron_store_path = config.workspace_path / "cron" / "jobs.json" cron = CronService(cron_store_path) # Create agent with cron service From ad99d5aaa060655347ec593ee613837667045c77 Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Tue, 3 Mar 2026 00:59:58 -0300 Subject: [PATCH 38/84] fix: merge consecutive user messages into single message Some LLM providers (Minimax, Dashscope) strictly reject consecutive messages with the same role. build_messages() was emitting two separate user messages back-to-back: the runtime context and the actual user content. Merge them into a single user message, handling both plain text and multimodal (image) content. Update _save_turn() to strip the runtime context prefix from the merged message when persisting to session history. Fixes #1414 Fixes #1344 --- nanobot/agent/context.py | 13 +++++++++++-- nanobot/agent/loop.py | 23 ++++++++++++++++------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 010b12628..df4825f79 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -112,11 +112,20 @@ Reply directly with text for conversations. Only use the 'message' tool to send chat_id: str | None = None, ) -> list[dict[str, Any]]: """Build the complete message list for an LLM call.""" + runtime_ctx = self._build_runtime_context(channel, chat_id) + user_content = self._build_user_content(current_message, media) + + # Merge runtime context and user content into a single user message + # to avoid consecutive same-role messages that some providers reject. + if isinstance(user_content, str): + merged = f"{runtime_ctx}\n\n{user_content}" + else: + merged = [{"type": "text", "text": runtime_ctx}] + user_content + return [ {"role": "system", "content": self.build_system_prompt(skill_names)}, *history, - {"role": "user", "content": self._build_runtime_context(channel, chat_id)}, - {"role": "user", "content": self._build_user_content(current_message, media)}, + {"role": "user", "content": merged}, ] def _build_user_content(self, text: str, media: list[str] | None) -> str | list[dict[str, Any]]: diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 488615d4c..825b11a7f 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -464,14 +464,23 @@ class AgentLoop: entry["content"] = content[:self._TOOL_RESULT_MAX_CHARS] + "\n... (truncated)" elif role == "user": if isinstance(content, str) and content.startswith(ContextBuilder._RUNTIME_CONTEXT_TAG): - continue + # Strip the runtime-context prefix, keep only the user text. + parts = content.split("\n\n", 1) + if len(parts) > 1 and parts[1].strip(): + entry["content"] = parts[1] + else: + continue if isinstance(content, list): - entry["content"] = [ - {"type": "text", "text": "[image]"} if ( - c.get("type") == "image_url" - and c.get("image_url", {}).get("url", "").startswith("data:image/") - ) else c for c in content - ] + filtered = [] + for c in content: + if c.get("type") == "text" and isinstance(c.get("text"), str) and c["text"].startswith(ContextBuilder._RUNTIME_CONTEXT_TAG): + continue # Strip runtime context from multimodal messages + if (c.get("type") == "image_url" + and c.get("image_url", {}).get("url", "").startswith("data:image/")): + filtered.append({"type": "text", "text": "[image]"}) + else: + filtered.append(c) + entry["content"] = filtered entry.setdefault("timestamp", datetime.now().isoformat()) session.messages.append(entry) session.updated_at = datetime.now() From da8a4fc68c6964e5ee7917b56769ccd39e1a86b6 Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Tue, 3 Mar 2026 01:02:33 -0300 Subject: [PATCH 39/84] fix: prevent cron job execution from scheduling new jobs When a cron job fires, the agent processes the scheduled message and has access to the cron tool. If the original message resembles a scheduling instruction (e.g. "remind me in 10 seconds"), the agent would call cron.add again, creating an infinite feedback loop. Add a cron-context flag to CronTool that blocks add operations during cron job execution. The flag is set before process_direct() and cleared in a finally block to ensure cleanup even on errors. Fixes #1441 --- nanobot/agent/tools/cron.py | 7 +++++++ nanobot/cli/commands.py | 21 +++++++++++++++------ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index fe1dce6d8..d360b14fd 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -14,12 +14,17 @@ class CronTool(Tool): self._cron = cron_service self._channel = "" self._chat_id = "" + self._in_cron_context = False def set_context(self, channel: str, chat_id: str) -> None: """Set the current session context for delivery.""" self._channel = channel self._chat_id = chat_id + def set_cron_context(self, active: bool) -> None: + """Mark whether the tool is executing inside a cron job callback.""" + self._in_cron_context = active + @property def name(self) -> str: return "cron" @@ -72,6 +77,8 @@ class CronTool(Tool): **kwargs: Any, ) -> str: if action == "add": + if self._in_cron_context: + return "Error: cannot schedule new jobs from within a cron job execution" return self._add_job(message, every_seconds, cron_expr, tz, at) elif action == "list": return self._list_jobs() diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 2662e9fe7..42c0c2d47 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -296,6 +296,7 @@ def gateway( # Set cron callback (needs agent) async def on_cron_job(job: CronJob) -> str | None: """Execute a cron job through the agent.""" + from nanobot.agent.tools.cron import CronTool from nanobot.agent.tools.message import MessageTool reminder_note = ( "[Scheduled Task] Timer finished.\n\n" @@ -303,12 +304,20 @@ def gateway( f"Scheduled instruction: {job.payload.message}" ) - response = await agent.process_direct( - reminder_note, - session_key=f"cron:{job.id}", - channel=job.payload.channel or "cli", - chat_id=job.payload.to or "direct", - ) + # Prevent the agent from scheduling new cron jobs during execution + cron_tool = agent.tools.get("cron") + if isinstance(cron_tool, CronTool): + cron_tool.set_cron_context(True) + try: + response = await agent.process_direct( + reminder_note, + session_key=f"cron:{job.id}", + channel=job.payload.channel or "cli", + chat_id=job.payload.to or "direct", + ) + finally: + if isinstance(cron_tool, CronTool): + cron_tool.set_cron_context(False) message_tool = agent.tools.get("message") if isinstance(message_tool, MessageTool) and message_tool._sent_in_turn: From 03b83fb79ee91833accd47ef9cf81d68eedcde62 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 3 Mar 2026 05:13:17 +0000 Subject: [PATCH 40/84] fix(agent): skip empty multimodal user entries after runtime-context strip --- nanobot/agent/loop.py | 2 ++ tests/test_loop_save_turn.py | 41 ++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 tests/test_loop_save_turn.py diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 825b11a7f..65a62e530 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -480,6 +480,8 @@ class AgentLoop: filtered.append({"type": "text", "text": "[image]"}) else: filtered.append(c) + if not filtered: + continue entry["content"] = filtered entry.setdefault("timestamp", datetime.now().isoformat()) session.messages.append(entry) diff --git a/tests/test_loop_save_turn.py b/tests/test_loop_save_turn.py new file mode 100644 index 000000000..aec6d1a9b --- /dev/null +++ b/tests/test_loop_save_turn.py @@ -0,0 +1,41 @@ +from nanobot.agent.context import ContextBuilder +from nanobot.agent.loop import AgentLoop +from nanobot.session.manager import Session + + +def _mk_loop() -> AgentLoop: + loop = AgentLoop.__new__(AgentLoop) + loop._TOOL_RESULT_MAX_CHARS = 500 + return loop + + +def test_save_turn_skips_multimodal_user_when_only_runtime_context() -> None: + loop = _mk_loop() + session = Session(key="test:runtime-only") + runtime = ContextBuilder._RUNTIME_CONTEXT_TAG + "\nCurrent Time: now (UTC)" + + loop._save_turn( + session, + [{"role": "user", "content": [{"type": "text", "text": runtime}]}], + skip=0, + ) + assert session.messages == [] + + +def test_save_turn_keeps_image_placeholder_after_runtime_strip() -> None: + loop = _mk_loop() + session = Session(key="test:image") + runtime = ContextBuilder._RUNTIME_CONTEXT_TAG + "\nCurrent Time: now (UTC)" + + loop._save_turn( + session, + [{ + "role": "user", + "content": [ + {"type": "text", "text": runtime}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}, + ], + }], + skip=0, + ) + assert session.messages[0]["content"] == [{"type": "text", "text": "[image]"}] From 30803afec0b704651666d9df3debd2225c64e1ae Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 3 Mar 2026 05:36:48 +0000 Subject: [PATCH 41/84] fix(cron): isolate cron-execution guard with contextvars --- nanobot/agent/tools/cron.py | 13 +++++++++---- nanobot/cli/commands.py | 7 ++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index d360b14fd..13b1e1238 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -1,5 +1,6 @@ """Cron tool for scheduling reminders and tasks.""" +from contextvars import ContextVar from typing import Any from nanobot.agent.tools.base import Tool @@ -14,16 +15,20 @@ class CronTool(Tool): self._cron = cron_service self._channel = "" self._chat_id = "" - self._in_cron_context = False + self._in_cron_context: ContextVar[bool] = ContextVar("cron_in_context", default=False) def set_context(self, channel: str, chat_id: str) -> None: """Set the current session context for delivery.""" self._channel = channel self._chat_id = chat_id - def set_cron_context(self, active: bool) -> None: + def set_cron_context(self, active: bool): """Mark whether the tool is executing inside a cron job callback.""" - self._in_cron_context = active + return self._in_cron_context.set(active) + + def reset_cron_context(self, token) -> None: + """Restore previous cron context.""" + self._in_cron_context.reset(token) @property def name(self) -> str: @@ -77,7 +82,7 @@ class CronTool(Tool): **kwargs: Any, ) -> str: if action == "add": - if self._in_cron_context: + if self._in_cron_context.get(): return "Error: cannot schedule new jobs from within a cron job execution" return self._add_job(message, every_seconds, cron_expr, tz, at) elif action == "list": diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 42c0c2d47..f9fe3474c 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -306,8 +306,9 @@ def gateway( # Prevent the agent from scheduling new cron jobs during execution cron_tool = agent.tools.get("cron") + cron_token = None if isinstance(cron_tool, CronTool): - cron_tool.set_cron_context(True) + cron_token = cron_tool.set_cron_context(True) try: response = await agent.process_direct( reminder_note, @@ -316,8 +317,8 @@ def gateway( chat_id=job.payload.to or "direct", ) finally: - if isinstance(cron_tool, CronTool): - cron_tool.set_cron_context(False) + if isinstance(cron_tool, CronTool) and cron_token is not None: + cron_tool.reset_cron_context(cron_token) message_tool = agent.tools.get("message") if isinstance(message_tool, MessageTool) and message_tool._sent_in_turn: From c05cb2ef64ce8eaa0257e1ae677a64ea7309f243 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 3 Mar 2026 05:51:24 +0000 Subject: [PATCH 42/84] refactor(cron): remove CLI cron commands and unify scheduling via cron tool --- README.md | 17 --- nanobot/cli/commands.py | 215 ------------------------------------ nanobot/templates/AGENTS.md | 8 +- 3 files changed, 3 insertions(+), 237 deletions(-) diff --git a/README.md b/README.md index 01da22899..33cdeeedd 100644 --- a/README.md +++ b/README.md @@ -901,23 +901,6 @@ MCP tools are automatically discovered and registered on startup. The LLM can us Interactive mode exits: `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`. -
-Scheduled Tasks (Cron) - -```bash -# Add a job -nanobot cron add --name "daily" --message "Good morning!" --cron "0 9 * * *" -nanobot cron add --name "hourly" --message "Check status" --every 3600 - -# List jobs -nanobot cron list - -# Remove a job -nanobot cron remove -``` - -
-
Heartbeat (Periodic Tasks) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index f9fe3474c..b75a2bc0f 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -782,221 +782,6 @@ def channels_login(): console.print("[red]npm not found. Please install Node.js.[/red]") -# ============================================================================ -# Cron Commands -# ============================================================================ - -cron_app = typer.Typer(help="Manage scheduled tasks") -app.add_typer(cron_app, name="cron") - - -@cron_app.command("list") -def cron_list( - all: bool = typer.Option(False, "--all", "-a", help="Include disabled jobs"), -): - """List scheduled jobs.""" - from nanobot.config.loader import get_data_dir - from nanobot.cron.service import CronService - - store_path = get_data_dir() / "cron" / "jobs.json" - service = CronService(store_path) - - jobs = service.list_jobs(include_disabled=all) - - if not jobs: - console.print("No scheduled jobs.") - return - - table = Table(title="Scheduled Jobs") - table.add_column("ID", style="cyan") - table.add_column("Name") - table.add_column("Schedule") - table.add_column("Status") - table.add_column("Next Run") - - import time - from datetime import datetime as _dt - from zoneinfo import ZoneInfo - for job in jobs: - # Format schedule - if job.schedule.kind == "every": - sched = f"every {(job.schedule.every_ms or 0) // 1000}s" - elif job.schedule.kind == "cron": - sched = f"{job.schedule.expr or ''} ({job.schedule.tz})" if job.schedule.tz else (job.schedule.expr or "") - else: - sched = "one-time" - - # Format next run - next_run = "" - if job.state.next_run_at_ms: - ts = job.state.next_run_at_ms / 1000 - try: - tz = ZoneInfo(job.schedule.tz) if job.schedule.tz else None - next_run = _dt.fromtimestamp(ts, tz).strftime("%Y-%m-%d %H:%M") - except Exception: - next_run = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) - - status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]" - - table.add_row(job.id, job.name, sched, status, next_run) - - console.print(table) - - -@cron_app.command("add") -def cron_add( - name: str = typer.Option(..., "--name", "-n", help="Job name"), - message: str = typer.Option(..., "--message", "-m", help="Message for agent"), - every: int = typer.Option(None, "--every", "-e", help="Run every N seconds"), - cron_expr: str = typer.Option(None, "--cron", "-c", help="Cron expression (e.g. '0 9 * * *')"), - tz: str | None = typer.Option(None, "--tz", help="IANA timezone for cron (e.g. 'America/Vancouver')"), - at: str = typer.Option(None, "--at", help="Run once at time (ISO format)"), - deliver: bool = typer.Option(False, "--deliver", "-d", help="Deliver response to channel"), - to: str = typer.Option(None, "--to", help="Recipient for delivery"), - channel: str = typer.Option(None, "--channel", help="Channel for delivery (e.g. 'telegram', 'whatsapp')"), -): - """Add a scheduled job.""" - from nanobot.config.loader import get_data_dir - from nanobot.cron.service import CronService - from nanobot.cron.types import CronSchedule - - if tz and not cron_expr: - console.print("[red]Error: --tz can only be used with --cron[/red]") - raise typer.Exit(1) - - # Determine schedule type - if every: - schedule = CronSchedule(kind="every", every_ms=every * 1000) - elif cron_expr: - schedule = CronSchedule(kind="cron", expr=cron_expr, tz=tz) - elif at: - import datetime - dt = datetime.datetime.fromisoformat(at) - schedule = CronSchedule(kind="at", at_ms=int(dt.timestamp() * 1000)) - else: - console.print("[red]Error: Must specify --every, --cron, or --at[/red]") - raise typer.Exit(1) - - store_path = get_data_dir() / "cron" / "jobs.json" - service = CronService(store_path) - - try: - job = service.add_job( - name=name, - schedule=schedule, - message=message, - deliver=deliver, - to=to, - channel=channel, - ) - except ValueError as e: - console.print(f"[red]Error: {e}[/red]") - raise typer.Exit(1) from e - - console.print(f"[green]โœ“[/green] Added job '{job.name}' ({job.id})") - - -@cron_app.command("remove") -def cron_remove( - job_id: str = typer.Argument(..., help="Job ID to remove"), -): - """Remove a scheduled job.""" - from nanobot.config.loader import get_data_dir - from nanobot.cron.service import CronService - - store_path = get_data_dir() / "cron" / "jobs.json" - service = CronService(store_path) - - if service.remove_job(job_id): - console.print(f"[green]โœ“[/green] Removed job {job_id}") - else: - console.print(f"[red]Job {job_id} not found[/red]") - - -@cron_app.command("enable") -def cron_enable( - job_id: str = typer.Argument(..., help="Job ID"), - disable: bool = typer.Option(False, "--disable", help="Disable instead of enable"), -): - """Enable or disable a job.""" - from nanobot.config.loader import get_data_dir - from nanobot.cron.service import CronService - - store_path = get_data_dir() / "cron" / "jobs.json" - service = CronService(store_path) - - job = service.enable_job(job_id, enabled=not disable) - if job: - status = "disabled" if disable else "enabled" - console.print(f"[green]โœ“[/green] Job '{job.name}' {status}") - else: - console.print(f"[red]Job {job_id} not found[/red]") - - -@cron_app.command("run") -def cron_run( - job_id: str = typer.Argument(..., help="Job ID to run"), - force: bool = typer.Option(False, "--force", "-f", help="Run even if disabled"), -): - """Manually run a job.""" - from loguru import logger - - from nanobot.agent.loop import AgentLoop - from nanobot.bus.queue import MessageBus - from nanobot.config.loader import get_data_dir, load_config - from nanobot.cron.service import CronService - from nanobot.cron.types import CronJob - logger.disable("nanobot") - - config = load_config() - provider = _make_provider(config) - bus = MessageBus() - agent_loop = AgentLoop( - bus=bus, - provider=provider, - workspace=config.workspace_path, - model=config.agents.defaults.model, - temperature=config.agents.defaults.temperature, - max_tokens=config.agents.defaults.max_tokens, - max_iterations=config.agents.defaults.max_tool_iterations, - memory_window=config.agents.defaults.memory_window, - reasoning_effort=config.agents.defaults.reasoning_effort, - brave_api_key=config.tools.web.search.api_key or None, - web_proxy=config.tools.web.proxy or None, - exec_config=config.tools.exec, - restrict_to_workspace=config.tools.restrict_to_workspace, - mcp_servers=config.tools.mcp_servers, - channels_config=config.channels, - ) - - store_path = get_data_dir() / "cron" / "jobs.json" - service = CronService(store_path) - - result_holder = [] - - async def on_job(job: CronJob) -> str | None: - response = await agent_loop.process_direct( - job.payload.message, - session_key=f"cron:{job.id}", - channel=job.payload.channel or "cli", - chat_id=job.payload.to or "direct", - ) - result_holder.append(response) - return response - - service.on_job = on_job - - async def run(): - return await service.run_job(job_id, force=force) - - if asyncio.run(run()): - console.print("[green]โœ“[/green] Job executed") - if result_holder: - _print_agent_response(result_holder[0], render_markdown=True) - else: - console.print(f"[red]Failed to run job {job_id}[/red]") - - # ============================================================================ # Status Commands # ============================================================================ diff --git a/nanobot/templates/AGENTS.md b/nanobot/templates/AGENTS.md index 4c3e5b1aa..a24604bb6 100644 --- a/nanobot/templates/AGENTS.md +++ b/nanobot/templates/AGENTS.md @@ -4,17 +4,15 @@ You are a helpful AI assistant. Be concise, accurate, and friendly. ## Scheduled Reminders -When user asks for a reminder at a specific time, use `exec` to run: -``` -nanobot cron add --name "reminder" --message "Your message" --at "YYYY-MM-DDTHH:MM:SS" --deliver --to "USER_ID" --channel "CHANNEL" -``` +Before scheduling reminders, check available skills and follow skill guidance first. +Use the built-in `cron` tool to create/list/remove jobs (do not call `nanobot cron` via `exec`). Get USER_ID and CHANNEL from the current session (e.g., `8281248569` and `telegram` from `telegram:8281248569`). **Do NOT just write reminders to MEMORY.md** โ€” that won't trigger actual notifications. ## Heartbeat Tasks -`HEARTBEAT.md` is checked every 30 minutes. Use file tools to manage periodic tasks: +`HEARTBEAT.md` is checked on the configured heartbeat interval. Use file tools to manage periodic tasks: - **Add**: `edit_file` to append new tasks - **Remove**: `edit_file` to delete completed tasks From 5f7fb9c75ad1d3d442d4236607c827ad97a132fd Mon Sep 17 00:00:00 2001 From: cocolato Date: Tue, 3 Mar 2026 23:40:56 +0800 Subject: [PATCH 43/84] add missed dependency --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index a22053c49..4199af17e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ "prompt-toolkit>=3.0.50,<4.0.0", "mcp>=1.26.0,<2.0.0", "json-repair>=0.57.0,<1.0.0", + "openai>=2.8.0", ] [project.optional-dependencies] From 102b9716ed154782a7d17be720e0a4a888889156 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20S=C3=A1nchez=20Vall=C3=A9s?= Date: Tue, 3 Mar 2026 17:16:08 +0100 Subject: [PATCH 44/84] feat: Implement Telegram draft/progress messages (streaming) --- nanobot/channels/telegram.py | 38 ++++++++++++++++++++++++++---------- pyproject.toml | 5 ++++- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index c290535fd..5f739e5fa 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -269,23 +269,41 @@ class TelegramChannel(BaseChannel): # Send text content if msg.content and msg.content != "[empty message]": + is_progress = msg.metadata.get("_progress", False) + draft_id = msg.metadata.get("message_id") + for chunk in _split_message(msg.content): try: html = _markdown_to_telegram_html(chunk) - await self._app.bot.send_message( - chat_id=chat_id, - text=html, - parse_mode="HTML", - reply_parameters=reply_params - ) - except Exception as e: - logger.warning("HTML parse failed, falling back to plain text: {}", e) - try: + if is_progress and draft_id: + await self._app.bot.send_message_draft( + chat_id=chat_id, + draft_id=draft_id, + text=html, + parse_mode="HTML" + ) + else: await self._app.bot.send_message( chat_id=chat_id, - text=chunk, + text=html, + parse_mode="HTML", reply_parameters=reply_params ) + except Exception as e: + logger.warning("HTML parse failed (or draft send failed), falling back to plain text: {}", e) + try: + if is_progress and draft_id: + await self._app.bot.send_message_draft( + chat_id=chat_id, + draft_id=draft_id, + text=chunk + ) + else: + await self._app.bot.send_message( + chat_id=chat_id, + text=chunk, + reply_parameters=reply_params + ) except Exception as e2: logger.error("Error sending Telegram message: {}", e2) diff --git a/pyproject.toml b/pyproject.toml index a22053c49..42f6194c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ dependencies = [ "rich>=14.0.0,<15.0.0", "croniter>=6.0.0,<7.0.0", "dingtalk-stream>=0.24.0,<1.0.0", - "python-telegram-bot[socks]>=22.0,<23.0", + "python-telegram-bot[socks] @ git+https://github.com/python-telegram-bot/python-telegram-bot.git@master", "lark-oapi>=1.5.0,<2.0.0", "socksio>=1.0.0,<2.0.0", "python-socketio>=5.16.0,<6.0.0", @@ -63,6 +63,9 @@ nanobot = "nanobot.cli.commands:app" requires = ["hatchling"] build-backend = "hatchling.build" +[tool.hatch.metadata] +allow-direct-references = true + [tool.hatch.build.targets.wheel] packages = ["nanobot"] From 3e83425142334c6d712c210ac73254488f749150 Mon Sep 17 00:00:00 2001 From: worenidewen Date: Wed, 4 Mar 2026 01:06:04 +0800 Subject: [PATCH 45/84] feat(mcp): add SSE transport support with auto-detection --- nanobot/agent/tools/mcp.py | 37 ++++++++++++++++++-- nanobot/config/schema.py | 72 ++++++++++++++++++++++++++------------ 2 files changed, 83 insertions(+), 26 deletions(-) diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index 37464e107..151aa55de 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -62,12 +62,43 @@ async def connect_mcp_servers( for name, cfg in mcp_servers.items(): try: - if cfg.command: + transport_type = cfg.type + if not transport_type: + if cfg.command: + transport_type = "stdio" + elif cfg.url: + transport_type = ( + "sse" if cfg.url.rstrip("/").endswith("/sse") else "streamableHttp" + ) + else: + logger.warning("MCP server '{}': no command or url configured, skipping", name) + continue + + if transport_type == "stdio": params = StdioServerParameters( command=cfg.command, args=cfg.args, env=cfg.env or None ) read, write = await stack.enter_async_context(stdio_client(params)) - elif cfg.url: + elif transport_type == "sse": + from mcp.client.sse import sse_client + + def httpx_client_factory( + headers: dict[str, str] | None = None, + timeout: httpx.Timeout | None = None, + auth: httpx.Auth | None = None, + ) -> httpx.AsyncClient: + merged_headers = {**(cfg.headers or {}), **(headers or {})} + return httpx.AsyncClient( + headers=merged_headers or None, + follow_redirects=True, + timeout=timeout, + auth=auth, + ) + + read, write = await stack.enter_async_context( + sse_client(cfg.url, httpx_client_factory=httpx_client_factory) + ) + elif transport_type == "streamableHttp": from mcp.client.streamable_http import streamable_http_client # Always provide an explicit httpx client so MCP HTTP transport does not # inherit httpx's default 5s timeout and preempt the higher-level tool timeout. @@ -82,7 +113,7 @@ async def connect_mcp_servers( streamable_http_client(cfg.url, http_client=http_client) ) else: - logger.warning("MCP server '{}': no command or url configured, skipping", name) + logger.warning("MCP server '{}': unknown transport type '{}'", name, transport_type) continue session = await stack.enter_async_context(ClientSession(read, write)) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 61a7bd242..64e60dc18 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -29,7 +29,9 @@ class TelegramConfig(Base): enabled: bool = False token: str = "" # Bot token from @BotFather allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames - proxy: str | None = None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" + proxy: str | None = ( + None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" + ) reply_to_message: bool = False # If true, bot replies quote the original message @@ -42,7 +44,9 @@ class FeishuConfig(Base): encrypt_key: str = "" # Encrypt Key for event subscription (optional) verification_token: str = "" # Verification Token for event subscription (optional) allow_from: list[str] = Field(default_factory=list) # Allowed user open_ids - react_emoji: str = "THUMBSUP" # Emoji type for message reactions (e.g. THUMBSUP, OK, DONE, SMILE) + react_emoji: str = ( + "THUMBSUP" # Emoji type for message reactions (e.g. THUMBSUP, OK, DONE, SMILE) + ) class DingTalkConfig(Base): @@ -72,9 +76,13 @@ class MatrixConfig(Base): access_token: str = "" user_id: str = "" # @bot:matrix.org device_id: str = "" - e2ee_enabled: bool = True # Enable Matrix E2EE support (encryption + encrypted room handling). - sync_stop_grace_seconds: int = 2 # Max seconds to wait for sync_forever to stop gracefully before cancellation fallback. - max_media_bytes: int = 20 * 1024 * 1024 # Max attachment size accepted for Matrix media handling (inbound + outbound). + e2ee_enabled: bool = True # Enable Matrix E2EE support (encryption + encrypted room handling). + sync_stop_grace_seconds: int = ( + 2 # Max seconds to wait for sync_forever to stop gracefully before cancellation fallback. + ) + max_media_bytes: int = ( + 20 * 1024 * 1024 + ) # Max attachment size accepted for Matrix media handling (inbound + outbound). allow_from: list[str] = Field(default_factory=list) group_policy: Literal["open", "mention", "allowlist"] = "open" group_allow_from: list[str] = Field(default_factory=list) @@ -105,7 +113,9 @@ class EmailConfig(Base): from_address: str = "" # Behavior - auto_reply_enabled: bool = True # If false, inbound email is read but no automatic reply is sent + auto_reply_enabled: bool = ( + True # If false, inbound email is read but no automatic reply is sent + ) poll_interval_seconds: int = 30 mark_seen: bool = True max_body_chars: int = 12000 @@ -183,27 +193,32 @@ class QQConfig(Base): enabled: bool = False app_id: str = "" # ๆœบๅ™จไบบ ID (AppID) from q.qq.com secret: str = "" # ๆœบๅ™จไบบๅฏ†้’ฅ (AppSecret) from q.qq.com - allow_from: list[str] = Field(default_factory=list) # Allowed user openids (empty = public access) + allow_from: list[str] = Field( + default_factory=list + ) # Allowed user openids (empty = public access) + class MatrixConfig(Base): """Matrix (Element) channel configuration.""" + enabled: bool = False homeserver: str = "https://matrix.org" access_token: str = "" - user_id: str = "" # e.g. @bot:matrix.org + user_id: str = "" # e.g. @bot:matrix.org device_id: str = "" - e2ee_enabled: bool = True # end-to-end encryption support - sync_stop_grace_seconds: int = 2 # graceful sync_forever shutdown timeout - max_media_bytes: int = 20 * 1024 * 1024 # inbound + outbound attachment limit + e2ee_enabled: bool = True # end-to-end encryption support + sync_stop_grace_seconds: int = 2 # graceful sync_forever shutdown timeout + max_media_bytes: int = 20 * 1024 * 1024 # inbound + outbound attachment limit allow_from: list[str] = Field(default_factory=list) group_policy: Literal["open", "mention", "allowlist"] = "open" group_allow_from: list[str] = Field(default_factory=list) allow_room_mentions: bool = False + class ChannelsConfig(Base): """Configuration for chat channels.""" - send_progress: bool = True # stream agent's text progress to the channel + send_progress: bool = True # stream agent's text progress to the channel send_tool_hints: bool = False # stream tool-call hints (e.g. read_file("โ€ฆ")) whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig) telegram: TelegramConfig = Field(default_factory=TelegramConfig) @@ -222,7 +237,9 @@ class AgentDefaults(Base): workspace: str = "~/.nanobot/workspace" model: str = "anthropic/claude-opus-4-5" - provider: str = "auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection + provider: str = ( + "auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection + ) max_tokens: int = 8192 temperature: float = 0.1 max_tool_iterations: int = 40 @@ -260,8 +277,12 @@ class ProvidersConfig(Base): moonshot: ProviderConfig = Field(default_factory=ProviderConfig) minimax: ProviderConfig = Field(default_factory=ProviderConfig) aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway - siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (็ก…ๅŸบๆตๅŠจ) API gateway - volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (็ซๅฑฑๅผ•ๆ“Ž) API gateway + siliconflow: ProviderConfig = Field( + default_factory=ProviderConfig + ) # SiliconFlow (็ก…ๅŸบๆตๅŠจ) API gateway + volcengine: ProviderConfig = Field( + default_factory=ProviderConfig + ) # VolcEngine (็ซๅฑฑๅผ•ๆ“Ž) API gateway openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth) github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth) @@ -291,7 +312,9 @@ class WebSearchConfig(Base): class WebToolsConfig(Base): """Web tools configuration.""" - proxy: str | None = None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" + proxy: str | None = ( + None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" + ) search: WebSearchConfig = Field(default_factory=WebSearchConfig) @@ -305,12 +328,13 @@ class ExecToolConfig(Base): class MCPServerConfig(Base): """MCP server connection configuration (stdio or HTTP).""" - command: str = "" # Stdio: command to run (e.g. "npx") - args: list[str] = Field(default_factory=list) # Stdio: command arguments - env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars - url: str = "" # HTTP: streamable HTTP endpoint URL - headers: dict[str, str] = Field(default_factory=dict) # HTTP: Custom HTTP Headers - tool_timeout: int = 30 # Seconds before a tool call is cancelled + type: Literal["stdio", "sse", "streamableHttp"] | None = None + command: str = "" + args: list[str] = Field(default_factory=list) + env: dict[str, str] = Field(default_factory=dict) + url: str = "" + headers: dict[str, str] = Field(default_factory=dict) + tool_timeout: int = 30 class ToolsConfig(Base): @@ -336,7 +360,9 @@ class Config(BaseSettings): """Get expanded workspace path.""" return Path(self.agents.defaults.workspace).expanduser() - def _match_provider(self, model: str | None = None) -> tuple["ProviderConfig | None", str | None]: + def _match_provider( + self, model: str | None = None + ) -> tuple["ProviderConfig | None", str | None]: """Match provider config and its registry name. Returns (config, spec_name).""" from nanobot.providers.registry import PROVIDERS From d0a48ed23c7eb578702f9dd5e7d4dc009d022efa Mon Sep 17 00:00:00 2001 From: Liwx Date: Wed, 4 Mar 2026 14:00:40 +0800 Subject: [PATCH 46/84] Update qq.py --- nanobot/channels/qq.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 7b171bc45..99a712b51 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -56,6 +56,7 @@ class QQChannel(BaseChannel): self.config: QQConfig = config self._client: "botpy.Client | None" = None self._processed_ids: deque = deque(maxlen=1000) + self._msg_seq: int = 1 # Message sequence to avoid QQ API deduplication async def start(self) -> None: """Start the QQ bot.""" From 20bec3bc266ef84399d3170cef6b4b5de8627f67 Mon Sep 17 00:00:00 2001 From: Liwx Date: Wed, 4 Mar 2026 14:06:19 +0800 Subject: [PATCH 47/84] Update qq.py --- nanobot/channels/qq.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 99a712b51..6c5804900 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -56,7 +56,7 @@ class QQChannel(BaseChannel): self.config: QQConfig = config self._client: "botpy.Client | None" = None self._processed_ids: deque = deque(maxlen=1000) - self._msg_seq: int = 1 # Message sequence to avoid QQ API deduplication + self._msg_seq: int = 1 # ๆถˆๆฏๅบๅˆ—ๅท๏ผŒ้ฟๅ…่ขซ QQ API ๅŽป้‡ async def start(self) -> None: """Start the QQ bot.""" @@ -103,11 +103,13 @@ class QQChannel(BaseChannel): return try: msg_id = msg.metadata.get("message_id") + self._msg_seq += 1 # ้€’ๅขžๅบๅˆ—ๅท await self._client.api.post_c2c_message( openid=msg.chat_id, msg_type=0, content=msg.content, msg_id=msg_id, + msg_seq=self._msg_seq, # ๆทปๅŠ ๅบๅˆ—ๅท้ฟๅ…ๅŽป้‡ ) except Exception as e: logger.error("Error sending QQ message: {}", e) @@ -134,3 +136,4 @@ class QQChannel(BaseChannel): ) except Exception: logger.exception("Error handling QQ message") + From df8d09f2b6c0eb23298e41acbe139fad9d38f325 Mon Sep 17 00:00:00 2001 From: Kiplangatkorir Date: Wed, 4 Mar 2026 10:53:30 +0300 Subject: [PATCH 48/84] fix: guard validate_params against non-dict input When the LLM returns malformed tool arguments (e.g. a list or string instead of a dict), validate_params would crash with AttributeError in _validate() when calling val.items(). Now returns a clear validation error instead of crashing. --- nanobot/agent/tools/base.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/nanobot/agent/tools/base.py b/nanobot/agent/tools/base.py index 8dd82c783..051fc9acf 100644 --- a/nanobot/agent/tools/base.py +++ b/nanobot/agent/tools/base.py @@ -54,6 +54,8 @@ class Tool(ABC): def validate_params(self, params: dict[str, Any]) -> list[str]: """Validate tool parameters against JSON schema. Returns error list (empty if valid).""" + if not isinstance(params, dict): + return [f"parameters must be an object, got {type(params).__name__}"] schema = self.parameters or {} if schema.get("type", "object") != "object": raise ValueError(f"Schema must be object type, got {schema.get('type')!r}") From edaf7a244a0d65395cab954fc768dc8031489b29 Mon Sep 17 00:00:00 2001 From: Kiplangatkorir Date: Wed, 4 Mar 2026 10:55:17 +0300 Subject: [PATCH 49/84] fix: handle invalid ISO datetime in CronTool gracefully datetime.fromisoformat(at) raises ValueError for malformed strings, which propagated uncaught and crashed the tool execution. Now catches ValueError and returns a user-friendly error message instead. --- nanobot/agent/tools/cron.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index 13b1e1238..f8e737b39 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -122,7 +122,10 @@ class CronTool(Tool): elif at: from datetime import datetime - dt = datetime.fromisoformat(at) + try: + dt = datetime.fromisoformat(at) + except ValueError: + return f"Error: invalid ISO datetime format '{at}'. Expected format: YYYY-MM-DDTHH:MM:SS" at_ms = int(dt.timestamp() * 1000) schedule = CronSchedule(kind="at", at_ms=at_ms) delete_after = True From ce65f8c11be13b51f242890cabdf15f4e0d1b12a Mon Sep 17 00:00:00 2001 From: Kiplangatkorir Date: Wed, 4 Mar 2026 11:15:45 +0300 Subject: [PATCH 50/84] fix: add size limit to ReadFileTool to prevent OOM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReadFileTool had no file size check โ€” reading a multi-GB file would load everything into memory and crash the process. Now: - Rejects files over ~512KB at the byte level (fast stat check) - Truncates at 128K chars with a notice if content is too long - Guides the agent to use exec with head/tail/grep for large files This matches the protection already in ExecTool (10KB) and WebFetchTool (50KB). --- nanobot/agent/tools/filesystem.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index bbdd49c2d..7b0b86725 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -26,6 +26,8 @@ def _resolve_path( class ReadFileTool(Tool): """Tool to read file contents.""" + _MAX_CHARS = 128_000 # ~128 KB โ€” prevents OOM from reading huge files into LLM context + def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None): self._workspace = workspace self._allowed_dir = allowed_dir @@ -54,7 +56,16 @@ class ReadFileTool(Tool): if not file_path.is_file(): return f"Error: Not a file: {path}" + size = file_path.stat().st_size + if size > self._MAX_CHARS * 4: # rough upper bound (UTF-8 chars โ‰ค 4 bytes) + return ( + f"Error: File too large ({size:,} bytes). " + f"Use exec tool with head/tail/grep to read portions." + ) + content = file_path.read_text(encoding="utf-8") + if len(content) > self._MAX_CHARS: + return content[: self._MAX_CHARS] + f"\n\n... (truncated โ€” file is {len(content):,} chars, limit {self._MAX_CHARS:,})" return content except PermissionError as e: return f"Error: {e}" From 61f658e04519ea7e711e6be707765bfd8ee9257d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20S=C3=A1nchez=20Vall=C3=A9s?= Date: Wed, 4 Mar 2026 12:11:18 +0100 Subject: [PATCH 51/84] add reasoning content to on progress message --- nanobot/agent/loop.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 65a62e530..5eea6e610 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -202,9 +202,16 @@ class AgentLoop: if response.has_tool_calls: if on_progress: - clean = self._strip_think(response.content) - if clean: - await on_progress(clean) + thoughts = [ + self._strip_think(response.content), + response.reasoning_content, + *(f"Thinking [{b.get('signature', '...')}]:\n{b.get('thought', '...')}" + for b in (response.thinking_blocks or []) if isinstance(b, dict) and "signature" in b) + ] + + if combined := "\n\n".join(filter(None, thoughts)): + await on_progress(combined) + await on_progress(self._tool_hint(response.tool_calls), tool_hint=True) tool_call_dicts = [ From ca1f41562c11aadb1e9db9bdaace83cd684db31d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20S=C3=A1nchez=20Vall=C3=A9s?= Date: Wed, 4 Mar 2026 13:19:35 +0100 Subject: [PATCH 52/84] Fix telegram stop typing if not final message --- nanobot/channels/telegram.py | 4 +++- pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 5f739e5fa..de95a15f5 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -225,7 +225,9 @@ class TelegramChannel(BaseChannel): logger.warning("Telegram bot not running") return - self._stop_typing(msg.chat_id) + # Only stop typing indicator for final responses + if not msg.metadata.get("_progress", False): + self._stop_typing(msg.chat_id) try: chat_id = int(msg.chat_id) diff --git a/pyproject.toml b/pyproject.toml index 42f6194c3..7ffe8f514 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ dependencies = [ "rich>=14.0.0,<15.0.0", "croniter>=6.0.0,<7.0.0", "dingtalk-stream>=0.24.0,<1.0.0", - "python-telegram-bot[socks] @ git+https://github.com/python-telegram-bot/python-telegram-bot.git@master", + "python-telegram-bot[socks]>=22.0,<23.0", "lark-oapi>=1.5.0,<2.0.0", "socksio>=1.0.0,<2.0.0", "python-socketio>=5.16.0,<6.0.0", From bb8512ca842fc3b14c6dee01c5aaf9e241f8344e Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Wed, 4 Mar 2026 20:42:49 +0800 Subject: [PATCH 53/84] test: fix test failures from refactored cron and context builder - test_context_prompt_cache: Update test to reflect merged runtime context and user message (commit ad99d5a merged them into one) - Remove test_cron_commands.py: cron add CLI command was removed in commit c05cb2e (unified scheduling via cron tool) --- tests/test_context_prompt_cache.py | 19 +++++++++---------- tests/test_cron_commands.py | 29 ----------------------------- 2 files changed, 9 insertions(+), 39 deletions(-) delete mode 100644 tests/test_cron_commands.py diff --git a/tests/test_context_prompt_cache.py b/tests/test_context_prompt_cache.py index 9afcc7dd9..ce796e22d 100644 --- a/tests/test_context_prompt_cache.py +++ b/tests/test_context_prompt_cache.py @@ -40,7 +40,7 @@ def test_system_prompt_stays_stable_when_clock_changes(tmp_path, monkeypatch) -> def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None: - """Runtime metadata should be a separate user message before the actual user message.""" + """Runtime metadata should be merged with the user message.""" workspace = _make_workspace(tmp_path) builder = ContextBuilder(workspace) @@ -54,13 +54,12 @@ def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None: assert messages[0]["role"] == "system" assert "## Current Session" not in messages[0]["content"] - assert messages[-2]["role"] == "user" - runtime_content = messages[-2]["content"] - assert isinstance(runtime_content, str) - assert ContextBuilder._RUNTIME_CONTEXT_TAG in runtime_content - assert "Current Time:" in runtime_content - assert "Channel: cli" in runtime_content - assert "Chat ID: direct" in runtime_content - + # Runtime context is now merged with user message into a single message assert messages[-1]["role"] == "user" - assert messages[-1]["content"] == "Return exactly: OK" + user_content = messages[-1]["content"] + assert isinstance(user_content, str) + assert ContextBuilder._RUNTIME_CONTEXT_TAG in user_content + assert "Current Time:" in user_content + assert "Channel: cli" in user_content + assert "Chat ID: direct" in user_content + assert "Return exactly: OK" in user_content diff --git a/tests/test_cron_commands.py b/tests/test_cron_commands.py deleted file mode 100644 index bce1ef55a..000000000 --- a/tests/test_cron_commands.py +++ /dev/null @@ -1,29 +0,0 @@ -from typer.testing import CliRunner - -from nanobot.cli.commands import app - -runner = CliRunner() - - -def test_cron_add_rejects_invalid_timezone(monkeypatch, tmp_path) -> None: - monkeypatch.setattr("nanobot.config.loader.get_data_dir", lambda: tmp_path) - - result = runner.invoke( - app, - [ - "cron", - "add", - "--name", - "demo", - "--message", - "hello", - "--cron", - "0 9 * * *", - "--tz", - "America/Vancovuer", - ], - ) - - assert result.exit_code == 1 - assert "Error: unknown timezone 'America/Vancovuer'" in result.stdout - assert not (tmp_path / "cron" / "jobs.json").exists() From ecdf30940459a27311855a97cfdb7599cb3f89a2 Mon Sep 17 00:00:00 2001 From: Daniel Emden Date: Wed, 4 Mar 2026 15:31:56 +0100 Subject: [PATCH 54/84] fix(codex): pass reasoning_effort to Codex API The OpenAI Codex provider accepts reasoning_effort but silently discards it. Wire it through as {"reasoning": {"effort": ...}} in the request body so the config option actually takes effect. --- nanobot/providers/openai_codex_provider.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py index b6afa6507..d04e21056 100644 --- a/nanobot/providers/openai_codex_provider.py +++ b/nanobot/providers/openai_codex_provider.py @@ -52,6 +52,9 @@ class OpenAICodexProvider(LLMProvider): "parallel_tool_calls": True, } + if reasoning_effort: + body["reasoning"] = {"effort": reasoning_effort} + if tools: body["tools"] = _convert_tools(tools) From c64fe0afd8cfcbfe0c26569140db33b473f87854 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20S=C3=A1nchez=20Vall=C3=A9s?= Date: Wed, 4 Mar 2026 16:53:07 +0100 Subject: [PATCH 55/84] fix(tests): resolve failing tests on main branch - Unskip matrix logic by adding missing deps (matrix-nio, nh3, mistune) - Update matrix tests for 'allow_from' default deny security change - Fix asyncio typing keepalive leak in matrix tests - Update context prompt cache assert after runtime message merge - Fix flaky cron service test with mtime sleep - Remove obsolete test_cron_commands.py testing deleted CLI commands --- pyproject.toml | 3 +++ tests/test_context_prompt_cache.py | 9 ++++----- tests/test_cron_commands.py | 29 ----------------------------- tests/test_cron_service.py | 2 ++ tests/test_matrix_channel.py | 20 ++++++++++++++++++-- 5 files changed, 27 insertions(+), 36 deletions(-) delete mode 100644 tests/test_cron_commands.py diff --git a/pyproject.toml b/pyproject.toml index a22053c49..05465238a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,9 @@ dev = [ "pytest>=9.0.0,<10.0.0", "pytest-asyncio>=1.3.0,<2.0.0", "ruff>=0.1.0", + "matrix-nio[e2e]>=0.25.2", + "mistune>=3.0.0,<4.0.0", + "nh3>=0.2.17,<1.0.0", ] [project.scripts] diff --git a/tests/test_context_prompt_cache.py b/tests/test_context_prompt_cache.py index 9afcc7dd9..38b8d3528 100644 --- a/tests/test_context_prompt_cache.py +++ b/tests/test_context_prompt_cache.py @@ -54,13 +54,12 @@ def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None: assert messages[0]["role"] == "system" assert "## Current Session" not in messages[0]["content"] - assert messages[-2]["role"] == "user" - runtime_content = messages[-2]["content"] + assert len(messages) == 2 + assert messages[-1]["role"] == "user" + runtime_content = messages[-1]["content"] assert isinstance(runtime_content, str) assert ContextBuilder._RUNTIME_CONTEXT_TAG in runtime_content assert "Current Time:" in runtime_content assert "Channel: cli" in runtime_content assert "Chat ID: direct" in runtime_content - - assert messages[-1]["role"] == "user" - assert messages[-1]["content"] == "Return exactly: OK" + assert "Return exactly: OK" in runtime_content diff --git a/tests/test_cron_commands.py b/tests/test_cron_commands.py deleted file mode 100644 index bce1ef55a..000000000 --- a/tests/test_cron_commands.py +++ /dev/null @@ -1,29 +0,0 @@ -from typer.testing import CliRunner - -from nanobot.cli.commands import app - -runner = CliRunner() - - -def test_cron_add_rejects_invalid_timezone(monkeypatch, tmp_path) -> None: - monkeypatch.setattr("nanobot.config.loader.get_data_dir", lambda: tmp_path) - - result = runner.invoke( - app, - [ - "cron", - "add", - "--name", - "demo", - "--message", - "hello", - "--cron", - "0 9 * * *", - "--tz", - "America/Vancovuer", - ], - ) - - assert result.exit_code == 1 - assert "Error: unknown timezone 'America/Vancovuer'" in result.stdout - assert not (tmp_path / "cron" / "jobs.json").exists() diff --git a/tests/test_cron_service.py b/tests/test_cron_service.py index 2a36f4c39..9631da5ae 100644 --- a/tests/test_cron_service.py +++ b/tests/test_cron_service.py @@ -48,6 +48,8 @@ async def test_running_service_honors_external_disable(tmp_path) -> None: ) await service.start() try: + # Wait slightly to ensure file mtime is definitively different + await asyncio.sleep(0.05) external = CronService(store_path) updated = external.enable_job(job.id, enabled=False) assert updated is not None diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index c6714c2e7..c25b95aef 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -159,6 +159,7 @@ class _FakeAsyncClient: def _make_config(**kwargs) -> MatrixConfig: + kwargs.setdefault("allow_from", ["*"]) return MatrixConfig( enabled=True, homeserver="https://matrix.org", @@ -274,7 +275,7 @@ async def test_stop_stops_sync_forever_before_close(monkeypatch) -> None: @pytest.mark.asyncio -async def test_room_invite_joins_when_allow_list_is_empty() -> None: +async def test_room_invite_ignores_when_allow_list_is_empty() -> None: channel = MatrixChannel(_make_config(allow_from=[]), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client @@ -284,9 +285,22 @@ async def test_room_invite_joins_when_allow_list_is_empty() -> None: await channel._on_room_invite(room, event) - assert client.join_calls == ["!room:matrix.org"] + assert client.join_calls == [] +@pytest.mark.asyncio +async def test_room_invite_joins_when_sender_allowed() -> None: + channel = MatrixChannel(_make_config(allow_from=["@alice:matrix.org"]), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + room = SimpleNamespace(room_id="!room:matrix.org") + event = SimpleNamespace(sender="@alice:matrix.org") + + await channel._on_room_invite(room, event) + + assert client.join_calls == ["!room:matrix.org"] + @pytest.mark.asyncio async def test_room_invite_respects_allow_list_when_configured() -> None: channel = MatrixChannel(_make_config(allow_from=["@bob:matrix.org"]), MessageBus()) @@ -1163,6 +1177,8 @@ async def test_send_progress_keeps_typing_keepalive_running() -> None: assert "!room:matrix.org" in channel._typing_tasks assert client.typing_calls[-1] == ("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS) + await channel.stop() + @pytest.mark.asyncio async def test_send_clears_typing_when_send_fails() -> None: From 88d7642c1ec570e07eef473f47d1d637b38b9b07 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Wed, 4 Mar 2026 20:42:49 +0800 Subject: [PATCH 56/84] test: fix test failures from refactored cron and context builder - test_context_prompt_cache: Update test to reflect merged runtime context and user message (commit ad99d5a merged them into one) - Remove test_cron_commands.py: cron add CLI command was removed in commit c05cb2e (unified scheduling via cron tool) --- tests/test_context_prompt_cache.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/test_context_prompt_cache.py b/tests/test_context_prompt_cache.py index 38b8d3528..fa7f02de7 100644 --- a/tests/test_context_prompt_cache.py +++ b/tests/test_context_prompt_cache.py @@ -40,7 +40,7 @@ def test_system_prompt_stays_stable_when_clock_changes(tmp_path, monkeypatch) -> def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None: - """Runtime metadata should be a separate user message before the actual user message.""" + """Runtime metadata should be merged with the user message.""" workspace = _make_workspace(tmp_path) builder = ContextBuilder(workspace) @@ -55,11 +55,12 @@ def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None: assert "## Current Session" not in messages[0]["content"] assert len(messages) == 2 + # Runtime context is now merged with user message into a single message assert messages[-1]["role"] == "user" - runtime_content = messages[-1]["content"] - assert isinstance(runtime_content, str) - assert ContextBuilder._RUNTIME_CONTEXT_TAG in runtime_content - assert "Current Time:" in runtime_content - assert "Channel: cli" in runtime_content - assert "Chat ID: direct" in runtime_content - assert "Return exactly: OK" in runtime_content + user_content = messages[-1]["content"] + assert isinstance(user_content, str) + assert ContextBuilder._RUNTIME_CONTEXT_TAG in user_content + assert "Current Time:" in user_content + assert "Channel: cli" in user_content + assert "Chat ID: direct" in user_content + assert "Return exactly: OK" in user_content From bdfe7d6449dab772f681b857ad76796c92b63d05 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 5 Mar 2026 00:16:31 +0800 Subject: [PATCH 57/84] fix(feishu): convert audio type to file for API compatibility Feishu's GetMessageResource API only accepts 'image' or 'file' as the type parameter. When downloading voice messages, nanobot was passing 'audio' which caused the API to reject the request with an error. This fix converts 'audio' to 'file' in _download_file_sync method before making the API call, allowing voice messages to be downloaded and transcribed successfully. Fixes voice message download failure in Feishu channel. --- nanobot/channels/feishu.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 0a0a5e492..a9a32b2bf 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -530,6 +530,10 @@ class FeishuChannel(BaseChannel): self, message_id: str, file_key: str, resource_type: str = "file" ) -> tuple[bytes | None, str | None]: """Download a file/audio/media from a Feishu message by message_id and file_key.""" + # Feishu API only accepts 'image' or 'file' as type parameter + # Convert 'audio' to 'file' for API compatibility + if resource_type == "audio": + resource_type = "file" try: request = ( GetMessageResourceRequest.builder() From 0209ad57d9655d8fea5f5e551a4bb89bd0f1691c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20S=C3=A1nchez=20Vall=C3=A9s?= Date: Wed, 4 Mar 2026 19:31:39 +0100 Subject: [PATCH 58/84] fix(tests): resolve RequestsDependencyWarning and lark-oapi asyncio/websockets DeprecationWarnings --- nanobot/channels/feishu.py | 32 +++++++++++--------------------- pyproject.toml | 1 + 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 0a0a5e492..7d26fa800 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -16,26 +16,9 @@ from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.schema import FeishuConfig -try: - import lark_oapi as lark - from lark_oapi.api.im.v1 import ( - CreateFileRequest, - CreateFileRequestBody, - CreateImageRequest, - CreateImageRequestBody, - CreateMessageReactionRequest, - CreateMessageReactionRequestBody, - CreateMessageRequest, - CreateMessageRequestBody, - Emoji, - GetMessageResourceRequest, - P2ImMessageReceiveV1, - ) - FEISHU_AVAILABLE = True -except ImportError: - FEISHU_AVAILABLE = False - lark = None - Emoji = None +import importlib.util + +FEISHU_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None # Message type display mapping MSG_TYPE_MAP = { @@ -280,6 +263,7 @@ class FeishuChannel(BaseChannel): logger.error("Feishu app_id and app_secret not configured") return + import lark_oapi as lark self._running = True self._loop = asyncio.get_running_loop() @@ -340,6 +324,7 @@ class FeishuChannel(BaseChannel): def _add_reaction_sync(self, message_id: str, emoji_type: str) -> None: """Sync helper for adding reaction (runs in thread pool).""" + from lark_oapi.api.im.v1 import CreateMessageReactionRequest, CreateMessageReactionRequestBody, Emoji try: request = CreateMessageReactionRequest.builder() \ .message_id(message_id) \ @@ -364,7 +349,7 @@ class FeishuChannel(BaseChannel): Common emoji types: THUMBSUP, OK, EYES, DONE, OnIt, HEART """ - if not self._client or not Emoji: + if not self._client: return loop = asyncio.get_running_loop() @@ -456,6 +441,7 @@ class FeishuChannel(BaseChannel): def _upload_image_sync(self, file_path: str) -> str | None: """Upload an image to Feishu and return the image_key.""" + from lark_oapi.api.im.v1 import CreateImageRequest, CreateImageRequestBody try: with open(file_path, "rb") as f: request = CreateImageRequest.builder() \ @@ -479,6 +465,7 @@ class FeishuChannel(BaseChannel): def _upload_file_sync(self, file_path: str) -> str | None: """Upload a file to Feishu and return the file_key.""" + from lark_oapi.api.im.v1 import CreateFileRequest, CreateFileRequestBody ext = os.path.splitext(file_path)[1].lower() file_type = self._FILE_TYPE_MAP.get(ext, "stream") file_name = os.path.basename(file_path) @@ -506,6 +493,7 @@ class FeishuChannel(BaseChannel): 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.""" + from lark_oapi.api.im.v1 import GetMessageResourceRequest try: request = GetMessageResourceRequest.builder() \ .message_id(message_id) \ @@ -530,6 +518,7 @@ class FeishuChannel(BaseChannel): self, message_id: str, file_key: str, resource_type: str = "file" ) -> tuple[bytes | None, str | None]: """Download a file/audio/media from a Feishu message by message_id and file_key.""" + from lark_oapi.api.im.v1 import GetMessageResourceRequest try: request = ( GetMessageResourceRequest.builder() @@ -598,6 +587,7 @@ class FeishuChannel(BaseChannel): 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.""" + from lark_oapi.api.im.v1 import CreateMessageRequest, CreateMessageRequestBody try: request = CreateMessageRequest.builder() \ .receive_id_type(receive_id_type) \ diff --git a/pyproject.toml b/pyproject.toml index 05465238a..d384f3fc8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ "prompt-toolkit>=3.0.50,<4.0.0", "mcp>=1.26.0,<2.0.0", "json-repair>=0.57.0,<1.0.0", + "chardet>=3.0.2,<6.0.0", ] [project.optional-dependencies] From e032faaeff81d7e4fa39659badbacc7b4004dc05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20S=C3=A1nchez=20Vall=C3=A9s?= Date: Wed, 4 Mar 2026 20:04:00 +0100 Subject: [PATCH 59/84] Merge branch 'main' of upstream/main into fix/test-failures --- .gitignore | 2 +- nanobot/agent/tools/base.py | 2 ++ nanobot/agent/tools/cron.py | 5 ++++- nanobot/agent/tools/filesystem.py | 11 +++++++++++ nanobot/channels/feishu.py | 6 ++++++ nanobot/providers/openai_codex_provider.py | 3 +++ pyproject.toml | 2 ++ tests/test_context_prompt_cache.py | 1 + 8 files changed, 30 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index d7b930d41..742d593f4 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,4 @@ __pycache__/ poetry.lock .pytest_cache/ botpy.log -tests/ + diff --git a/nanobot/agent/tools/base.py b/nanobot/agent/tools/base.py index 8dd82c783..051fc9acf 100644 --- a/nanobot/agent/tools/base.py +++ b/nanobot/agent/tools/base.py @@ -54,6 +54,8 @@ class Tool(ABC): def validate_params(self, params: dict[str, Any]) -> list[str]: """Validate tool parameters against JSON schema. Returns error list (empty if valid).""" + if not isinstance(params, dict): + return [f"parameters must be an object, got {type(params).__name__}"] schema = self.parameters or {} if schema.get("type", "object") != "object": raise ValueError(f"Schema must be object type, got {schema.get('type')!r}") diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index 13b1e1238..f8e737b39 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -122,7 +122,10 @@ class CronTool(Tool): elif at: from datetime import datetime - dt = datetime.fromisoformat(at) + try: + dt = datetime.fromisoformat(at) + except ValueError: + return f"Error: invalid ISO datetime format '{at}'. Expected format: YYYY-MM-DDTHH:MM:SS" at_ms = int(dt.timestamp() * 1000) schedule = CronSchedule(kind="at", at_ms=at_ms) delete_after = True diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index bbdd49c2d..7b0b86725 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -26,6 +26,8 @@ def _resolve_path( class ReadFileTool(Tool): """Tool to read file contents.""" + _MAX_CHARS = 128_000 # ~128 KB โ€” prevents OOM from reading huge files into LLM context + def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None): self._workspace = workspace self._allowed_dir = allowed_dir @@ -54,7 +56,16 @@ class ReadFileTool(Tool): if not file_path.is_file(): return f"Error: Not a file: {path}" + size = file_path.stat().st_size + if size > self._MAX_CHARS * 4: # rough upper bound (UTF-8 chars โ‰ค 4 bytes) + return ( + f"Error: File too large ({size:,} bytes). " + f"Use exec tool with head/tail/grep to read portions." + ) + content = file_path.read_text(encoding="utf-8") + if len(content) > self._MAX_CHARS: + return content[: self._MAX_CHARS] + f"\n\n... (truncated โ€” file is {len(content):,} chars, limit {self._MAX_CHARS:,})" return content except PermissionError as e: return f"Error: {e}" diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 7d26fa800..0cd84c3b4 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -519,6 +519,12 @@ class FeishuChannel(BaseChannel): ) -> tuple[bytes | None, str | None]: """Download a file/audio/media from a Feishu message by message_id and file_key.""" from lark_oapi.api.im.v1 import GetMessageResourceRequest + + # Feishu API only accepts 'image' or 'file' as type parameter + # Convert 'audio' to 'file' for API compatibility + if resource_type == "audio": + resource_type = "file" + try: request = ( GetMessageResourceRequest.builder() diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py index b6afa6507..d04e21056 100644 --- a/nanobot/providers/openai_codex_provider.py +++ b/nanobot/providers/openai_codex_provider.py @@ -52,6 +52,9 @@ class OpenAICodexProvider(LLMProvider): "parallel_tool_calls": True, } + if reasoning_effort: + body["reasoning"] = {"effort": reasoning_effort} + if tools: body["tools"] = _convert_tools(tools) diff --git a/pyproject.toml b/pyproject.toml index d384f3fc8..e5214bd57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,8 @@ dependencies = [ "mcp>=1.26.0,<2.0.0", "json-repair>=0.57.0,<1.0.0", "chardet>=3.0.2,<6.0.0", + "openai>=2.8.0", + ] [project.optional-dependencies] diff --git a/tests/test_context_prompt_cache.py b/tests/test_context_prompt_cache.py index fa7f02de7..d347e5329 100644 --- a/tests/test_context_prompt_cache.py +++ b/tests/test_context_prompt_cache.py @@ -55,6 +55,7 @@ def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None: assert "## Current Session" not in messages[0]["content"] assert len(messages) == 2 + # Runtime context is now merged with user message into a single message assert messages[-1]["role"] == "user" user_content = messages[-1]["content"] From c27d2b15220b2cff00604c4143851b989792fedf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20S=C3=A1nchez=20Vall=C3=A9s?= Date: Thu, 5 Mar 2026 00:33:27 +0100 Subject: [PATCH 60/84] fix(agent): prevent tool hints from overwriting reasoning in streaming drafts --- nanobot/agent/loop.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 5eea6e610..fc1fd751c 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -209,10 +209,13 @@ class AgentLoop: for b in (response.thinking_blocks or []) if isinstance(b, dict) and "signature" in b) ] - if combined := "\n\n".join(filter(None, thoughts)): - await on_progress(combined) - - await on_progress(self._tool_hint(response.tool_calls), tool_hint=True) + combined_thoughts = "\n\n".join(filter(None, thoughts)) + tool_hint_str = self._tool_hint(response.tool_calls) + + if combined_thoughts: + await on_progress(f"{combined_thoughts}\n\n{tool_hint_str}", tool_hint=True) + else: + await on_progress(tool_hint_str, tool_hint=True) tool_call_dicts = [ { From 33f59d8a37a963f5fa694435155f42621d9852ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20S=C3=A1nchez=20Vall=C3=A9s?= Date: Thu, 5 Mar 2026 00:45:15 +0100 Subject: [PATCH 61/84] fix(agent): separate reasoning and tool hints to respect channel config --- nanobot/agent/loop.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index fc1fd751c..2f6a2bc91 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -213,6 +213,7 @@ class AgentLoop: tool_hint_str = self._tool_hint(response.tool_calls) if combined_thoughts: + await on_progress(combined_thoughts) await on_progress(f"{combined_thoughts}\n\n{tool_hint_str}", tool_hint=True) else: await on_progress(tool_hint_str, tool_hint=True) From a08aae93e6c8ac2b68a4a8d566899b95fd414844 Mon Sep 17 00:00:00 2001 From: hcanyz Date: Thu, 5 Mar 2026 11:33:20 +0800 Subject: [PATCH 62/84] fix: not imported when LiteLLMProvider is not used LiteLLM:WARNING: get_model_cost_map.py:213 - LiteLLM: Failed to fetch remote model cost map from https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json: The read operation timed out. Falling back to local backup. --- nanobot/cli/commands.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index b75a2bc0f..2597928c3 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -200,8 +200,6 @@ def onboard(): def _make_provider(config: Config): """Create the appropriate LLM provider from config.""" - from nanobot.providers.custom_provider import CustomProvider - from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.openai_codex_provider import OpenAICodexProvider model = config.agents.defaults.model @@ -213,6 +211,7 @@ def _make_provider(config: Config): return OpenAICodexProvider(default_model=model) # Custom: direct OpenAI-compatible endpoint, bypasses LiteLLM + from nanobot.providers.custom_provider import CustomProvider if provider_name == "custom": return CustomProvider( api_key=p.api_key if p else "no-key", @@ -220,6 +219,7 @@ def _make_provider(config: Config): default_model=model, ) + from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.registry import find_by_name spec = find_by_name(provider_name) if not model.startswith("bedrock/") and not (p and p.api_key) and not (spec and spec.is_oauth): From 5cc3c032450f5a97c1ebf9bf153974ad00ddc725 Mon Sep 17 00:00:00 2001 From: Peixian Gong Date: Thu, 5 Mar 2026 15:15:37 +0800 Subject: [PATCH 63/84] fix: merge tool_calls from multiple choices in LiteLLM response GitHub Copilot's API returns tool_calls split across multiple choices: - choices[0]: content only (tool_calls=null) - choices[1]: tool_calls only (content=null) The existing _parse_response only inspected choices[0], so tool_calls were silently lost, causing the agent to never execute tools when using github_copilot/ models. This fix scans all choices and merges tool_calls + content, so providers that return multi-choice responses work correctly. Single-choice providers (OpenAI, Anthropic, etc.) are unaffected since the loop over one choice is equivalent to the original code. --- nanobot/providers/litellm_provider.py | 44 +++++++++++++++++++-------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index d8d8acef9..a1819a29d 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -8,6 +8,7 @@ from typing import Any import json_repair import litellm from litellm import acompletion +from loguru import logger from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest from nanobot.providers.registry import find_by_model, find_gateway @@ -255,20 +256,37 @@ class LiteLLMProvider(LLMProvider): """Parse LiteLLM response into our standard format.""" choice = response.choices[0] message = choice.message + content = message.content + finish_reason = choice.finish_reason + + # Some providers (e.g. GitHub Copilot) split content and tool_calls + # across multiple choices. Merge them so tool_calls are not lost. + raw_tool_calls = [] + for ch in response.choices: + msg = ch.message + if hasattr(msg, "tool_calls") and msg.tool_calls: + raw_tool_calls.extend(msg.tool_calls) + if ch.finish_reason in ("tool_calls", "stop"): + finish_reason = ch.finish_reason + if not content and msg.content: + content = msg.content + + if len(response.choices) > 1: + logger.debug("LiteLLM response has {} choices, merged {} tool_calls", + len(response.choices), len(raw_tool_calls)) tool_calls = [] - if hasattr(message, "tool_calls") and message.tool_calls: - for tc in message.tool_calls: - # Parse arguments from JSON string if needed - args = tc.function.arguments - if isinstance(args, str): - args = json_repair.loads(args) + for tc in raw_tool_calls: + # Parse arguments from JSON string if needed + args = tc.function.arguments + if isinstance(args, str): + args = json_repair.loads(args) - tool_calls.append(ToolCallRequest( - id=_short_tool_id(), - name=tc.function.name, - arguments=args, - )) + tool_calls.append(ToolCallRequest( + id=_short_tool_id(), + name=tc.function.name, + arguments=args, + )) usage = {} if hasattr(response, "usage") and response.usage: @@ -282,9 +300,9 @@ class LiteLLMProvider(LLMProvider): thinking_blocks = getattr(message, "thinking_blocks", None) or None return LLMResponse( - content=message.content, + content=content, tool_calls=tool_calls, - finish_reason=choice.finish_reason or "stop", + finish_reason=finish_reason or "stop", usage=usage, reasoning_content=reasoning_content, thinking_blocks=thinking_blocks, From cf3e7e3f38325224dcb342af448ecd17c11d1d13 Mon Sep 17 00:00:00 2001 From: ouyangwulin Date: Thu, 5 Mar 2026 16:54:15 +0800 Subject: [PATCH 64/84] feat: Add Alibaba Cloud Coding Plan API support Add dashscope_coding_plan provider to registry with OpenAI-compatible endpoint for BaiLian coding assistance. - Supports API key detection by 'sk-sp-' prefix pattern - Adds provider config schema entry for proper loading - Updates documentation with configuration instructions - Fixes duplicate MatrixConfig class issue in schema - Follow existing nanobot provider patterns for consistency --- README.md | 1 + nanobot/config/schema.py | 62 +++++++++++----- nanobot/providers/registry.py | 130 ++++++++++++++++------------------ 3 files changed, 109 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index 33cdeeedd..2977ccb31 100644 --- a/README.md +++ b/README.md @@ -656,6 +656,7 @@ Config file: `~/.nanobot/config.json` > [!TIP] > - **Groq** provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed. > - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config. +> - **Alibaba Cloud Coding Plan**: If you're on the Alibaba Cloud Coding Plan (BaiLian coding assistance), add configuration for `dashscope_coding_plan` provider with an API key starting with `sk-sp-` in your config. This provider uses OpenAI-compatible endpoint `https://coding.dashscope.aliyuncs.com/v1`. > - **MiniMax (Mainland China)**: If your API key is from MiniMax's mainland China platform (minimaxi.com), set `"apiBase": "https://api.minimaxi.com/v1"` in your minimax provider config. > - **VolcEngine Coding Plan**: If you're on VolcEngine's coding plan, set `"apiBase": "https://ark.cn-beijing.volces.com/api/coding/v3"` in your volcengine provider config. diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 61a7bd242..538fab8cb 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -29,7 +29,9 @@ class TelegramConfig(Base): enabled: bool = False token: str = "" # Bot token from @BotFather allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames - proxy: str | None = None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" + proxy: str | None = ( + None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" + ) reply_to_message: bool = False # If true, bot replies quote the original message @@ -42,7 +44,9 @@ class FeishuConfig(Base): encrypt_key: str = "" # Encrypt Key for event subscription (optional) verification_token: str = "" # Verification Token for event subscription (optional) allow_from: list[str] = Field(default_factory=list) # Allowed user open_ids - react_emoji: str = "THUMBSUP" # Emoji type for message reactions (e.g. THUMBSUP, OK, DONE, SMILE) + react_emoji: str = ( + "THUMBSUP" # Emoji type for message reactions (e.g. THUMBSUP, OK, DONE, SMILE) + ) class DingTalkConfig(Base): @@ -72,9 +76,13 @@ class MatrixConfig(Base): access_token: str = "" user_id: str = "" # @bot:matrix.org device_id: str = "" - e2ee_enabled: bool = True # Enable Matrix E2EE support (encryption + encrypted room handling). - sync_stop_grace_seconds: int = 2 # Max seconds to wait for sync_forever to stop gracefully before cancellation fallback. - max_media_bytes: int = 20 * 1024 * 1024 # Max attachment size accepted for Matrix media handling (inbound + outbound). + e2ee_enabled: bool = True # Enable Matrix E2EE support (encryption + encrypted room handling). + sync_stop_grace_seconds: int = ( + 2 # Max seconds to wait for sync_forever to stop gracefully before cancellation fallback. + ) + max_media_bytes: int = ( + 20 * 1024 * 1024 + ) # Max attachment size accepted for Matrix media handling (inbound + outbound). allow_from: list[str] = Field(default_factory=list) group_policy: Literal["open", "mention", "allowlist"] = "open" group_allow_from: list[str] = Field(default_factory=list) @@ -105,7 +113,9 @@ class EmailConfig(Base): from_address: str = "" # Behavior - auto_reply_enabled: bool = True # If false, inbound email is read but no automatic reply is sent + auto_reply_enabled: bool = ( + True # If false, inbound email is read but no automatic reply is sent + ) poll_interval_seconds: int = 30 mark_seen: bool = True max_body_chars: int = 12000 @@ -183,27 +193,32 @@ class QQConfig(Base): enabled: bool = False app_id: str = "" # ๆœบๅ™จไบบ ID (AppID) from q.qq.com secret: str = "" # ๆœบๅ™จไบบๅฏ†้’ฅ (AppSecret) from q.qq.com - allow_from: list[str] = Field(default_factory=list) # Allowed user openids (empty = public access) + allow_from: list[str] = Field( + default_factory=list + ) # Allowed user openids (empty = public access) + class MatrixConfig(Base): """Matrix (Element) channel configuration.""" + enabled: bool = False homeserver: str = "https://matrix.org" access_token: str = "" - user_id: str = "" # e.g. @bot:matrix.org + user_id: str = "" # e.g. @bot:matrix.org device_id: str = "" - e2ee_enabled: bool = True # end-to-end encryption support - sync_stop_grace_seconds: int = 2 # graceful sync_forever shutdown timeout - max_media_bytes: int = 20 * 1024 * 1024 # inbound + outbound attachment limit + e2ee_enabled: bool = True # end-to-end encryption support + sync_stop_grace_seconds: int = 2 # graceful sync_forever shutdown timeout + max_media_bytes: int = 20 * 1024 * 1024 # inbound + outbound attachment limit allow_from: list[str] = Field(default_factory=list) group_policy: Literal["open", "mention", "allowlist"] = "open" group_allow_from: list[str] = Field(default_factory=list) allow_room_mentions: bool = False + class ChannelsConfig(Base): """Configuration for chat channels.""" - send_progress: bool = True # stream agent's text progress to the channel + send_progress: bool = True # stream agent's text progress to the channel send_tool_hints: bool = False # stream tool-call hints (e.g. read_file("โ€ฆ")) whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig) telegram: TelegramConfig = Field(default_factory=TelegramConfig) @@ -222,7 +237,9 @@ class AgentDefaults(Base): workspace: str = "~/.nanobot/workspace" model: str = "anthropic/claude-opus-4-5" - provider: str = "auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection + provider: str = ( + "auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection + ) max_tokens: int = 8192 temperature: float = 0.1 max_tool_iterations: int = 40 @@ -255,13 +272,20 @@ class ProvidersConfig(Base): groq: ProviderConfig = Field(default_factory=ProviderConfig) zhipu: ProviderConfig = Field(default_factory=ProviderConfig) dashscope: ProviderConfig = Field(default_factory=ProviderConfig) # ้˜ฟ้‡Œไบ‘้€šไน‰ๅƒ้—ฎ + dashscope_coding_plan: ProviderConfig = Field( + default_factory=ProviderConfig + ) # ้˜ฟ้‡Œไบ‘็™พ็‚ผCoding Plan vllm: ProviderConfig = Field(default_factory=ProviderConfig) gemini: ProviderConfig = Field(default_factory=ProviderConfig) moonshot: ProviderConfig = Field(default_factory=ProviderConfig) minimax: ProviderConfig = Field(default_factory=ProviderConfig) aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway - siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (็ก…ๅŸบๆตๅŠจ) API gateway - volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (็ซๅฑฑๅผ•ๆ“Ž) API gateway + siliconflow: ProviderConfig = Field( + default_factory=ProviderConfig + ) # SiliconFlow (็ก…ๅŸบๆตๅŠจ) API gateway + volcengine: ProviderConfig = Field( + default_factory=ProviderConfig + ) # VolcEngine (็ซๅฑฑๅผ•ๆ“Ž) API gateway openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth) github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth) @@ -291,7 +315,9 @@ class WebSearchConfig(Base): class WebToolsConfig(Base): """Web tools configuration.""" - proxy: str | None = None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" + proxy: str | None = ( + None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" + ) search: WebSearchConfig = Field(default_factory=WebSearchConfig) @@ -336,7 +362,9 @@ class Config(BaseSettings): """Get expanded workspace path.""" return Path(self.agents.defaults.workspace).expanduser() - def _match_provider(self, model: str | None = None) -> tuple["ProviderConfig | None", str | None]: + def _match_provider( + self, model: str | None = None + ) -> tuple["ProviderConfig | None", str | None]: """Match provider config and its registry name. Returns (config, spec_name).""" from nanobot.providers.registry import PROVIDERS diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index df915b71e..da04cd759 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -26,33 +26,33 @@ class ProviderSpec: """ # identity - name: str # config field name, e.g. "dashscope" - keywords: tuple[str, ...] # model-name keywords for matching (lowercase) - env_key: str # LiteLLM env var, e.g. "DASHSCOPE_API_KEY" - display_name: str = "" # shown in `nanobot status` + name: str # config field name, e.g. "dashscope" + keywords: tuple[str, ...] # model-name keywords for matching (lowercase) + env_key: str # LiteLLM env var, e.g. "DASHSCOPE_API_KEY" + display_name: str = "" # shown in `nanobot status` # model prefixing - litellm_prefix: str = "" # "dashscope" โ†’ model becomes "dashscope/{model}" - skip_prefixes: tuple[str, ...] = () # don't prefix if model already starts with these + litellm_prefix: str = "" # "dashscope" โ†’ model becomes "dashscope/{model}" + skip_prefixes: tuple[str, ...] = () # don't prefix if model already starts with these # extra env vars, e.g. (("ZHIPUAI_API_KEY", "{api_key}"),) env_extras: tuple[tuple[str, str], ...] = () # gateway / local detection - is_gateway: bool = False # routes any model (OpenRouter, AiHubMix) - is_local: bool = False # local deployment (vLLM, Ollama) - detect_by_key_prefix: str = "" # match api_key prefix, e.g. "sk-or-" - detect_by_base_keyword: str = "" # match substring in api_base URL - default_api_base: str = "" # fallback base URL + is_gateway: bool = False # routes any model (OpenRouter, AiHubMix) + is_local: bool = False # local deployment (vLLM, Ollama) + detect_by_key_prefix: str = "" # match api_key prefix, e.g. "sk-or-" + detect_by_base_keyword: str = "" # match substring in api_base URL + default_api_base: str = "" # fallback base URL # gateway behavior - strip_model_prefix: bool = False # strip "provider/" before re-prefixing + strip_model_prefix: bool = False # strip "provider/" before re-prefixing # per-model param overrides, e.g. (("kimi-k2.5", {"temperature": 1.0}),) model_overrides: tuple[tuple[str, dict[str, Any]], ...] = () # OAuth-based providers (e.g., OpenAI Codex) don't use API keys - is_oauth: bool = False # if True, uses OAuth flow instead of API key + is_oauth: bool = False # if True, uses OAuth flow instead of API key # Direct providers bypass LiteLLM entirely (e.g., CustomProvider) is_direct: bool = False @@ -70,7 +70,6 @@ class ProviderSpec: # --------------------------------------------------------------------------- PROVIDERS: tuple[ProviderSpec, ...] = ( - # === Custom (direct OpenAI-compatible endpoint, bypasses LiteLLM) ====== ProviderSpec( name="custom", @@ -80,17 +79,15 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( litellm_prefix="", is_direct=True, ), - # === Gateways (detected by api_key / api_base, not model name) ========= # Gateways can route any model, so they win in fallback. - # OpenRouter: global gateway, keys start with "sk-or-" ProviderSpec( name="openrouter", keywords=("openrouter",), env_key="OPENROUTER_API_KEY", display_name="OpenRouter", - litellm_prefix="openrouter", # claude-3 โ†’ openrouter/claude-3 + litellm_prefix="openrouter", # claude-3 โ†’ openrouter/claude-3 skip_prefixes=(), env_extras=(), is_gateway=True, @@ -102,16 +99,15 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( model_overrides=(), supports_prompt_caching=True, ), - # AiHubMix: global gateway, OpenAI-compatible interface. # strip_model_prefix=True: it doesn't understand "anthropic/claude-3", # so we strip to bare "claude-3" then re-prefix as "openai/claude-3". ProviderSpec( name="aihubmix", keywords=("aihubmix",), - env_key="OPENAI_API_KEY", # OpenAI-compatible + env_key="OPENAI_API_KEY", # OpenAI-compatible display_name="AiHubMix", - litellm_prefix="openai", # โ†’ openai/{model} + litellm_prefix="openai", # โ†’ openai/{model} skip_prefixes=(), env_extras=(), is_gateway=True, @@ -119,10 +115,9 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( detect_by_key_prefix="", detect_by_base_keyword="aihubmix", default_api_base="https://aihubmix.com/v1", - strip_model_prefix=True, # anthropic/claude-3 โ†’ claude-3 โ†’ openai/claude-3 + strip_model_prefix=True, # anthropic/claude-3 โ†’ claude-3 โ†’ openai/claude-3 model_overrides=(), ), - # SiliconFlow (็ก…ๅŸบๆตๅŠจ): OpenAI-compatible gateway, model names keep org prefix ProviderSpec( name="siliconflow", @@ -140,7 +135,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( strip_model_prefix=False, model_overrides=(), ), - # VolcEngine (็ซๅฑฑๅผ•ๆ“Ž): OpenAI-compatible gateway ProviderSpec( name="volcengine", @@ -158,9 +152,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( strip_model_prefix=False, model_overrides=(), ), - # === Standard providers (matched by model-name keywords) =============== - # Anthropic: LiteLLM recognizes "claude-*" natively, no prefix needed. ProviderSpec( name="anthropic", @@ -179,7 +171,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( model_overrides=(), supports_prompt_caching=True, ), - # OpenAI: LiteLLM recognizes "gpt-*" natively, no prefix needed. ProviderSpec( name="openai", @@ -197,14 +188,13 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( strip_model_prefix=False, model_overrides=(), ), - # OpenAI Codex: uses OAuth, not API key. ProviderSpec( name="openai_codex", keywords=("openai-codex",), - env_key="", # OAuth-based, no API key + env_key="", # OAuth-based, no API key display_name="OpenAI Codex", - litellm_prefix="", # Not routed through LiteLLM + litellm_prefix="", # Not routed through LiteLLM skip_prefixes=(), env_extras=(), is_gateway=False, @@ -214,16 +204,15 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( default_api_base="https://chatgpt.com/backend-api", strip_model_prefix=False, model_overrides=(), - is_oauth=True, # OAuth-based authentication + is_oauth=True, # OAuth-based authentication ), - # Github Copilot: uses OAuth, not API key. ProviderSpec( name="github_copilot", keywords=("github_copilot", "copilot"), - env_key="", # OAuth-based, no API key + env_key="", # OAuth-based, no API key display_name="Github Copilot", - litellm_prefix="github_copilot", # github_copilot/model โ†’ github_copilot/model + litellm_prefix="github_copilot", # github_copilot/model โ†’ github_copilot/model skip_prefixes=("github_copilot/",), env_extras=(), is_gateway=False, @@ -233,17 +222,16 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( default_api_base="", strip_model_prefix=False, model_overrides=(), - is_oauth=True, # OAuth-based authentication + is_oauth=True, # OAuth-based authentication ), - # DeepSeek: needs "deepseek/" prefix for LiteLLM routing. ProviderSpec( name="deepseek", keywords=("deepseek",), env_key="DEEPSEEK_API_KEY", display_name="DeepSeek", - litellm_prefix="deepseek", # deepseek-chat โ†’ deepseek/deepseek-chat - skip_prefixes=("deepseek/",), # avoid double-prefix + litellm_prefix="deepseek", # deepseek-chat โ†’ deepseek/deepseek-chat + skip_prefixes=("deepseek/",), # avoid double-prefix env_extras=(), is_gateway=False, is_local=False, @@ -253,15 +241,14 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( strip_model_prefix=False, model_overrides=(), ), - # Gemini: needs "gemini/" prefix for LiteLLM. ProviderSpec( name="gemini", keywords=("gemini",), env_key="GEMINI_API_KEY", display_name="Gemini", - litellm_prefix="gemini", # gemini-pro โ†’ gemini/gemini-pro - skip_prefixes=("gemini/",), # avoid double-prefix + litellm_prefix="gemini", # gemini-pro โ†’ gemini/gemini-pro + skip_prefixes=("gemini/",), # avoid double-prefix env_extras=(), is_gateway=False, is_local=False, @@ -271,7 +258,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( strip_model_prefix=False, model_overrides=(), ), - # Zhipu: LiteLLM uses "zai/" prefix. # Also mirrors key to ZHIPUAI_API_KEY (some LiteLLM paths check that). # skip_prefixes: don't add "zai/" when already routed via gateway. @@ -280,11 +266,9 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( keywords=("zhipu", "glm", "zai"), env_key="ZAI_API_KEY", display_name="Zhipu AI", - litellm_prefix="zai", # glm-4 โ†’ zai/glm-4 + litellm_prefix="zai", # glm-4 โ†’ zai/glm-4 skip_prefixes=("zhipu/", "zai/", "openrouter/", "hosted_vllm/"), - env_extras=( - ("ZHIPUAI_API_KEY", "{api_key}"), - ), + env_extras=(("ZHIPUAI_API_KEY", "{api_key}"),), is_gateway=False, is_local=False, detect_by_key_prefix="", @@ -293,14 +277,13 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( strip_model_prefix=False, model_overrides=(), ), - # DashScope: Qwen models, needs "dashscope/" prefix. ProviderSpec( name="dashscope", keywords=("qwen", "dashscope"), env_key="DASHSCOPE_API_KEY", display_name="DashScope", - litellm_prefix="dashscope", # qwen-max โ†’ dashscope/qwen-max + litellm_prefix="dashscope", # qwen-max โ†’ dashscope/qwen-max skip_prefixes=("dashscope/", "openrouter/"), env_extras=(), is_gateway=False, @@ -311,7 +294,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( strip_model_prefix=False, model_overrides=(), ), - # Moonshot: Kimi models, needs "moonshot/" prefix. # LiteLLM requires MOONSHOT_API_BASE env var to find the endpoint. # Kimi K2.5 API enforces temperature >= 1.0. @@ -320,22 +302,17 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( keywords=("moonshot", "kimi"), env_key="MOONSHOT_API_KEY", display_name="Moonshot", - litellm_prefix="moonshot", # kimi-k2.5 โ†’ moonshot/kimi-k2.5 + litellm_prefix="moonshot", # kimi-k2.5 โ†’ moonshot/kimi-k2.5 skip_prefixes=("moonshot/", "openrouter/"), - env_extras=( - ("MOONSHOT_API_BASE", "{api_base}"), - ), + env_extras=(("MOONSHOT_API_BASE", "{api_base}"),), is_gateway=False, is_local=False, detect_by_key_prefix="", detect_by_base_keyword="", - default_api_base="https://api.moonshot.ai/v1", # intl; use api.moonshot.cn for China + default_api_base="https://api.moonshot.ai/v1", # intl; use api.moonshot.cn for China strip_model_prefix=False, - model_overrides=( - ("kimi-k2.5", {"temperature": 1.0}), - ), + model_overrides=(("kimi-k2.5", {"temperature": 1.0}),), ), - # MiniMax: needs "minimax/" prefix for LiteLLM routing. # Uses OpenAI-compatible API at api.minimax.io/v1. ProviderSpec( @@ -343,7 +320,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( keywords=("minimax",), env_key="MINIMAX_API_KEY", display_name="MiniMax", - litellm_prefix="minimax", # MiniMax-M2.1 โ†’ minimax/MiniMax-M2.1 + litellm_prefix="minimax", # MiniMax-M2.1 โ†’ minimax/MiniMax-M2.1 skip_prefixes=("minimax/", "openrouter/"), env_extras=(), is_gateway=False, @@ -354,9 +331,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( strip_model_prefix=False, model_overrides=(), ), - # === Local deployment (matched by config key, NOT by api_base) ========= - # vLLM / any OpenAI-compatible local server. # Detected when config key is "vllm" (provider_name="vllm"). ProviderSpec( @@ -364,20 +339,38 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( keywords=("vllm",), env_key="HOSTED_VLLM_API_KEY", display_name="vLLM/Local", - litellm_prefix="hosted_vllm", # Llama-3-8B โ†’ hosted_vllm/Llama-3-8B + litellm_prefix="hosted_vllm", # Llama-3-8B โ†’ hosted_vllm/Llama-3-8B skip_prefixes=(), env_extras=(), is_gateway=False, is_local=True, detect_by_key_prefix="", detect_by_base_keyword="", - default_api_base="", # user must provide in config + default_api_base="", # user must provide in config + strip_model_prefix=False, + model_overrides=(), + ), + # === Coding Plan Gateway Providers ===================================== + # Alibaba Cloud Coding Plan: OpenAI-compatible gateway for coding assistance. + # Uses special API key format starting with "sk-sp-" to distinguish it + # from regular dashscope keys. Uses the OpenAI-compatible endpoint. + ProviderSpec( + name="dashscope_coding_plan", + keywords=("dashscope-coding-plan", "coding-plan", "aliyun-coding", "bailian-coding"), + env_key="DASHSCOPE_CODING_PLAN_API_KEY", + display_name="Alibaba Cloud Coding Plan", + litellm_prefix="dashscope", # โ†’ dashscope/{model} + skip_prefixes=("dashscope/", "openrouter/"), + env_extras=(), + is_gateway=True, + is_local=False, + detect_by_key_prefix="sk-sp-", # coding plan API keys start with "sk-sp-" + detect_by_base_keyword="coding.dashscope", + default_api_base="https://coding.dashscope.aliyuncs.com/v1", strip_model_prefix=False, model_overrides=(), ), - # === Auxiliary (not a primary LLM provider) ============================ - # Groq: mainly used for Whisper voice transcription, also usable for LLM. # Needs "groq/" prefix for LiteLLM routing. Placed last โ€” it rarely wins fallback. ProviderSpec( @@ -385,8 +378,8 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( keywords=("groq",), env_key="GROQ_API_KEY", display_name="Groq", - litellm_prefix="groq", # llama3-8b-8192 โ†’ groq/llama3-8b-8192 - skip_prefixes=("groq/",), # avoid double-prefix + litellm_prefix="groq", # llama3-8b-8192 โ†’ groq/llama3-8b-8192 + skip_prefixes=("groq/",), # avoid double-prefix env_extras=(), is_gateway=False, is_local=False, @@ -403,6 +396,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( # Lookup helpers # --------------------------------------------------------------------------- + def find_by_model(model: str) -> ProviderSpec | None: """Match a standard provider by model-name keyword (case-insensitive). Skips gateways/local โ€” those are matched by api_key/api_base instead.""" @@ -418,7 +412,9 @@ def find_by_model(model: str) -> ProviderSpec | None: return spec for spec in std_specs: - if any(kw in model_lower or kw.replace("-", "_") in model_normalized for kw in spec.keywords): + if any( + kw in model_lower or kw.replace("-", "_") in model_normalized for kw in spec.keywords + ): return spec return None From 323e5f22cc1be888e2b6f291233d9a96a97edd6c Mon Sep 17 00:00:00 2001 From: suger-m Date: Thu, 5 Mar 2026 11:14:04 +0800 Subject: [PATCH 65/84] refactor(channels): extract split_message utility to reduce code duplication Extract the _split_message function from discord.py and telegram.py into a shared utility function in utils/helpers.py. Changes: - Add split_message() to nanobot/utils/helpers.py with configurable max_len - Update Discord channel to use shared utility (2000 char limit) - Update Telegram channel to use shared utility (4000 char limit) - Remove duplicate implementations from both channels Benefits: - Reduces code duplication - Centralizes message splitting logic for easier maintenance - Makes the function reusable for future channels The function splits content into chunks within max_len, preferring to break at newlines or spaces rather than mid-word. --- nanobot/channels/discord.py | 25 ++----------------------- nanobot/channels/telegram.py | 25 ++++--------------------- nanobot/utils/helpers.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 44 deletions(-) diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index 57e59220c..436854094 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -13,34 +13,13 @@ from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.schema import DiscordConfig +from nanobot.utils.helpers import split_message 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 not content: - return [] - 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 <= 0: - pos = cut.rfind(' ') - if pos <= 0: - pos = max_len - chunks.append(content[:pos]) - content = content[pos:].lstrip() - return chunks - - class DiscordChannel(BaseChannel): """Discord channel using Gateway websocket.""" @@ -104,7 +83,7 @@ class DiscordChannel(BaseChannel): headers = {"Authorization": f"Bot {self.config.token}"} try: - chunks = _split_message(msg.content or "") + chunks = split_message(msg.content or "", MAX_MESSAGE_LEN) if not chunks: return diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index c290535fd..274218125 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -14,6 +14,9 @@ from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.schema import TelegramConfig +from nanobot.utils.helpers import split_message + +TELEGRAM_MAX_MESSAGE_LEN = 4000 # Telegram message character limit def _markdown_to_telegram_html(text: str) -> str: @@ -79,26 +82,6 @@ def _markdown_to_telegram_html(text: str) -> str: return text -def _split_message(content: str, max_len: int = 4000) -> 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 TelegramChannel(BaseChannel): """ Telegram channel using long polling. @@ -269,7 +252,7 @@ class TelegramChannel(BaseChannel): # Send text content if msg.content and msg.content != "[empty message]": - for chunk in _split_message(msg.content): + for chunk in split_message(msg.content, TELEGRAM_MAX_MESSAGE_LEN): try: html = _markdown_to_telegram_html(chunk) await self._app.bot.send_message( diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index 3a8c8023f..8fd81f979 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -34,6 +34,38 @@ def safe_filename(name: str) -> str: return _UNSAFE_CHARS.sub("_", name).strip() +def split_message(content: str, max_len: int = 2000) -> list[str]: + """ + Split content into chunks within max_len, preferring line breaks. + + Args: + content: The text content to split. + max_len: Maximum length per chunk (default 2000 for Discord compatibility). + + Returns: + List of message chunks, each within max_len. + """ + if not content: + return [] + 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] + # Try to break at newline first, then space, then hard break + pos = cut.rfind('\n') + if pos <= 0: + pos = cut.rfind(' ') + if pos <= 0: + pos = max_len + chunks.append(content[:pos]) + content = content[pos:].lstrip() + return chunks + + def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]: """Sync bundled templates to workspace. Only creates missing files.""" from importlib.resources import files as pkg_files From 97522bfa0309931e53782ed7a4e2cfdc470853c4 Mon Sep 17 00:00:00 2001 From: coldxiangyu Date: Thu, 5 Mar 2026 17:27:17 +0800 Subject: [PATCH 66/84] fix(feishu): isolate lark ws Client event loop from main asyncio loop Commit 0209ad5 moved `import lark_oapi as lark` inside the start() method (lazy import) to suppress DeprecationWarnings. This had an unintended side effect: the import now happens after the main asyncio loop is already running, so lark_oapi's module-level loop = asyncio.get_event_loop() captures the running main loop. When the WebSocket thread then calls loop.run_until_complete() inside Client.start(), Python raises: RuntimeError: This event loop is already running and the _connect/_disconnect coroutines are never awaited. Fix: in run_ws(), create a fresh event loop with asyncio.new_event_loop(), set it as the thread's current loop, and patch lark_oapi.ws.client.loop to point to this dedicated loop before calling Client.start(). The loop is closed on thread exit. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- nanobot/channels/feishu.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 0cd84c3b4..fcb70a8a8 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -290,16 +290,28 @@ class FeishuChannel(BaseChannel): log_level=lark.LogLevel.INFO ) - # Start WebSocket client in a separate thread with reconnect loop + # Start WebSocket client in a separate thread with reconnect loop. + # A dedicated event loop is created for this thread so that lark_oapi's + # module-level `loop = asyncio.get_event_loop()` picks up an idle loop + # instead of the already-running main asyncio loop, which would cause + # "This event loop is already running" errors. def run_ws(): - while self._running: - try: - self._ws_client.start() - except Exception as e: - logger.warning("Feishu WebSocket error: {}", e) - if self._running: - import time - time.sleep(5) + import time + import lark_oapi.ws.client as _lark_ws_client + ws_loop = asyncio.new_event_loop() + asyncio.set_event_loop(ws_loop) + # Patch the module-level loop used by lark's ws Client.start() + _lark_ws_client.loop = ws_loop + try: + while self._running: + try: + self._ws_client.start() + except Exception as e: + logger.warning("Feishu WebSocket error: {}", e) + if self._running: + time.sleep(5) + finally: + ws_loop.close() self._ws_thread = threading.Thread(target=run_ws, daemon=True) self._ws_thread.start() From 6770a6e7e9dbebd5ca3ed490a5dd0b30b7c3d7a3 Mon Sep 17 00:00:00 2001 From: ouyangwulin Date: Thu, 5 Mar 2026 17:34:36 +0800 Subject: [PATCH 67/84] supported aliyun coding plan. --- nanobot/config/schema.py | 34 ++++++++++++++++------------------ nanobot/providers/registry.py | 6 +++--- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 538fab8cb..15cf2b45e 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -5,7 +5,7 @@ from typing import Literal from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel -from pydantic_settings import BaseSettings +from pydantic_settings import BaseSettings, SettingsConfigDict class Base(BaseModel): @@ -198,23 +198,6 @@ class QQConfig(Base): ) # Allowed user openids (empty = public access) -class MatrixConfig(Base): - """Matrix (Element) channel configuration.""" - - enabled: bool = False - homeserver: str = "https://matrix.org" - access_token: str = "" - user_id: str = "" # e.g. @bot:matrix.org - device_id: str = "" - e2ee_enabled: bool = True # end-to-end encryption support - sync_stop_grace_seconds: int = 2 # graceful sync_forever shutdown timeout - max_media_bytes: int = 20 * 1024 * 1024 # inbound + outbound attachment limit - allow_from: list[str] = Field(default_factory=list) - group_policy: Literal["open", "mention", "allowlist"] = "open" - group_allow_from: list[str] = Field(default_factory=list) - allow_room_mentions: bool = False - - class ChannelsConfig(Base): """Configuration for chat channels.""" @@ -339,6 +322,20 @@ class MCPServerConfig(Base): tool_timeout: int = 30 # Seconds before a tool call is cancelled +class TTSConfig(Base): + """Text-to-Speech configuration.""" + + provider: str = "edge_tts" # Default TTS provider + voice: str = "en-US-ChristopherNeural" # Default voice + speed: float = 1.0 # Voice speed multiplier + + +class AudioConfig(Base): + """Audio configuration.""" + + tts: TTSConfig = Field(default_factory=TTSConfig) + + class ToolsConfig(Base): """Tools configuration.""" @@ -356,6 +353,7 @@ class Config(BaseSettings): providers: ProvidersConfig = Field(default_factory=ProvidersConfig) gateway: GatewayConfig = Field(default_factory=GatewayConfig) tools: ToolsConfig = Field(default_factory=ToolsConfig) + audio: AudioConfig = Field(default_factory=AudioConfig) @property def workspace_path(self) -> Path: diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index da04cd759..3b6659e10 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -359,15 +359,15 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( keywords=("dashscope-coding-plan", "coding-plan", "aliyun-coding", "bailian-coding"), env_key="DASHSCOPE_CODING_PLAN_API_KEY", display_name="Alibaba Cloud Coding Plan", - litellm_prefix="dashscope", # โ†’ dashscope/{model} - skip_prefixes=("dashscope/", "openrouter/"), + litellm_prefix="openai", # โ†’ openai/{model} (uses OpenAI-compatible endpoint) + skip_prefixes=("openai/", "dashscope/", "openrouter/"), env_extras=(), is_gateway=True, is_local=False, detect_by_key_prefix="sk-sp-", # coding plan API keys start with "sk-sp-" detect_by_base_keyword="coding.dashscope", default_api_base="https://coding.dashscope.aliyuncs.com/v1", - strip_model_prefix=False, + strip_model_prefix=True, # Strip "dashscope_coding_plan/" prefix model_overrides=(), ), # === Auxiliary (not a primary LLM provider) ============================ From 46192fbd2abe922390be1961819a86dc75c74321 Mon Sep 17 00:00:00 2001 From: coldxiangyu Date: Thu, 5 Mar 2026 20:18:13 +0800 Subject: [PATCH 68/84] fix(context): detect image MIME type from magic bytes instead of file extension Feishu downloads images with incorrect extensions (e.g. .jpg for PNG files). mimetypes.guess_type() relies on the file extension, causing a MIME mismatch that Anthropic rejects with 'image was specified using image/jpeg but appears to be image/png'. Fix: read the first bytes of the image data and detect the real MIME type via magic bytes (PNG: 0x89PNG, JPEG: 0xFFD8FF, GIF: GIF87a/GIF89a, WEBP: RIFF+WEBP). Fall back to mimetypes.guess_type() only when magic bytes are inconclusive. --- nanobot/agent/context.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index df4825f79..7ead317f3 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -12,6 +12,19 @@ from nanobot.agent.memory import MemoryStore from nanobot.agent.skills import SkillsLoader +def _detect_image_mime(data: bytes) -> str | None: + """Detect image MIME type from magic bytes, ignoring file extension.""" + if data[:8] == b"\x89PNG\r\n\x1a\n": + return "image/png" + if data[:3] == b"\xff\xd8\xff": + return "image/jpeg" + if data[:6] in (b"GIF87a", b"GIF89a"): + return "image/gif" + if data[:4] == b"RIFF" and data[8:12] == b"WEBP": + return "image/webp" + return None + + class ContextBuilder: """Builds the context (system prompt + messages) for the agent.""" @@ -136,10 +149,14 @@ Reply directly with text for conversations. Only use the 'message' tool to send images = [] for path in media: p = Path(path) - mime, _ = mimetypes.guess_type(path) - if not p.is_file() or not mime or not mime.startswith("image/"): + if not p.is_file(): continue - b64 = base64.b64encode(p.read_bytes()).decode() + raw = p.read_bytes() + # Detect real MIME type from magic bytes; fallback to filename guess + mime = _detect_image_mime(raw) or mimetypes.guess_type(path)[0] + if not mime or not mime.startswith("image/"): + continue + b64 = base64.b64encode(raw).decode() images.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}}) if not images: From fb77176cfd41b50b3495ffa99cfc22bb6cbd4ed1 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 5 Mar 2026 14:25:46 +0000 Subject: [PATCH 69/84] feat(custom-provider): keep instance-level session affinity header for cache locality --- nanobot/providers/custom_provider.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nanobot/providers/custom_provider.py b/nanobot/providers/custom_provider.py index 02183f3d9..66df734c6 100644 --- a/nanobot/providers/custom_provider.py +++ b/nanobot/providers/custom_provider.py @@ -16,6 +16,7 @@ class CustomProvider(LLMProvider): def __init__(self, api_key: str = "no-key", api_base: str = "http://localhost:8000/v1", default_model: str = "default"): super().__init__(api_key, api_base) self.default_model = default_model + # Keep affinity stable for this provider instance to improve backend cache locality. self._client = AsyncOpenAI( api_key=api_key, base_url=api_base, From 06fcd2cc3fed18667672f638a6c7cc54f8d5f736 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 5 Mar 2026 14:33:14 +0000 Subject: [PATCH 70/84] fix(discord): correct group_policy default to mention and style cleanup --- nanobot/channels/discord.py | 6 ++---- nanobot/config/schema.py | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index 85ff28a3f..900c17b15 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -174,7 +174,7 @@ class DiscordChannel(BaseChannel): # Capture bot user ID for mention detection user_data = payload.get("user") or {} self._bot_user_id = user_data.get("id") - logger.info(f"Discord bot connected as user {self._bot_user_id}") + logger.info("Discord bot connected as user {}", self._bot_user_id) elif op == 0 and event_type == "MESSAGE_CREATE": await self._handle_message_create(payload) elif op == 7: @@ -287,8 +287,6 @@ class DiscordChannel(BaseChannel): def _should_respond_in_group(self, payload: dict[str, Any], content: str) -> bool: """Check if bot should respond in a group channel based on policy.""" - channel_id = str(payload.get("channel_id", "")) - if self.config.group_policy == "open": return True @@ -303,7 +301,7 @@ class DiscordChannel(BaseChannel): # Also check content for mention format <@USER_ID> if f"<@{self._bot_user_id}>" in content or f"<@!{self._bot_user_id}>" in content: return True - logger.debug(f"Discord message in {channel_id} ignored (bot not mentioned)") + logger.debug("Discord message in {} ignored (bot not mentioned)", payload.get("channel_id")) return False return True diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index a6b609bf8..9d7da3bf2 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -62,7 +62,7 @@ class DiscordConfig(Base): allow_from: list[str] = Field(default_factory=list) # Allowed user IDs gateway_url: str = "wss://gateway.discord.gg/?v=10&encoding=json" intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT - group_policy: str = "open" # "mention" or "open" + group_policy: Literal["mention", "open"] = "mention" class MatrixConfig(Base): From b71c1bdca7dd0aa6323d7b8074bf4be25aa44a9b Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 5 Mar 2026 14:44:45 +0000 Subject: [PATCH 71/84] fix(mcp): hoist sse/http imports, annotate auto-detection heuristic, restore field comments --- README.md | 4 ++-- nanobot/agent/tools/mcp.py | 6 +++--- nanobot/config/schema.py | 14 +++++++------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 6c9304d62..5bc70b823 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,9 @@ ๐Ÿˆ **nanobot** is an **ultra-lightweight** personal AI assistant inspired by [OpenClaw](https://github.com/openclaw/openclaw) -โšก๏ธ Delivers core agent functionality in just **~4,000** lines of code โ€” **99% smaller** than Clawdbot's 430k+ lines. +โšก๏ธ Delivers core agent functionality with **99% fewer lines of code** than OpenClaw, making it more customizable and understandable. -๐Ÿ“ Real-time line count: **3,935 lines** (run `bash core_agent_lines.sh` to verify anytime) +๐Ÿ“ Real-time line count: run `bash core_agent_lines.sh` to verify anytime ## ๐Ÿ“ข News diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index 151aa55de..2cbffd09d 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -58,7 +58,9 @@ async def connect_mcp_servers( ) -> None: """Connect to configured MCP servers and register their tools.""" from mcp import ClientSession, StdioServerParameters + from mcp.client.sse import sse_client from mcp.client.stdio import stdio_client + from mcp.client.streamable_http import streamable_http_client for name, cfg in mcp_servers.items(): try: @@ -67,6 +69,7 @@ async def connect_mcp_servers( if cfg.command: transport_type = "stdio" elif cfg.url: + # Convention: URLs ending with /sse use SSE transport; others use streamableHttp transport_type = ( "sse" if cfg.url.rstrip("/").endswith("/sse") else "streamableHttp" ) @@ -80,8 +83,6 @@ async def connect_mcp_servers( ) read, write = await stack.enter_async_context(stdio_client(params)) elif transport_type == "sse": - from mcp.client.sse import sse_client - def httpx_client_factory( headers: dict[str, str] | None = None, timeout: httpx.Timeout | None = None, @@ -99,7 +100,6 @@ async def connect_mcp_servers( sse_client(cfg.url, httpx_client_factory=httpx_client_factory) ) elif transport_type == "streamableHttp": - from mcp.client.streamable_http import streamable_http_client # Always provide an explicit httpx client so MCP HTTP transport does not # inherit httpx's default 5s timeout and preempt the higher-level tool timeout. http_client = await stack.enter_async_context( diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 9f2e5b370..1f2f94687 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -329,13 +329,13 @@ class ExecToolConfig(Base): class MCPServerConfig(Base): """MCP server connection configuration (stdio or HTTP).""" - type: Literal["stdio", "sse", "streamableHttp"] | None = None - command: str = "" - args: list[str] = Field(default_factory=list) - env: dict[str, str] = Field(default_factory=dict) - url: str = "" - headers: dict[str, str] = Field(default_factory=dict) - tool_timeout: int = 30 + type: Literal["stdio", "sse", "streamableHttp"] | None = None # auto-detected if omitted + command: str = "" # Stdio: command to run (e.g. "npx") + args: list[str] = Field(default_factory=list) # Stdio: command arguments + env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars + url: str = "" # HTTP/SSE: endpoint URL + headers: dict[str, str] = Field(default_factory=dict) # HTTP/SSE: custom headers + tool_timeout: int = 30 # seconds before a tool call is cancelled class ToolsConfig(Base): From 57d8aefc2289144339640be677d5d4e3edfdcb6f Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 5 Mar 2026 14:46:03 +0000 Subject: [PATCH 72/84] docs: update introduction of nanobot --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5bc70b823..4c5e9a602 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,11 @@

-๐Ÿˆ **nanobot** is an **ultra-lightweight** personal AI assistant inspired by [OpenClaw](https://github.com/openclaw/openclaw) +๐Ÿˆ **nanobot** is an **ultra-lightweight** personal AI assistant inspired by [OpenClaw](https://github.com/openclaw/openclaw). โšก๏ธ Delivers core agent functionality with **99% fewer lines of code** than OpenClaw, making it more customizable and understandable. -๐Ÿ“ Real-time line count: run `bash core_agent_lines.sh` to verify anytime +๐Ÿ“ Real-time line count: run `bash core_agent_lines.sh` to verify anytime. ## ๐Ÿ“ข News From cd0bcc162e5a742e452918c4835384774d7a7938 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 5 Mar 2026 14:48:57 +0000 Subject: [PATCH 73/84] docs: update introduction of nanobot --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4c5e9a602..1374fb8e5 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ ๐Ÿˆ **nanobot** is an **ultra-lightweight** personal AI assistant inspired by [OpenClaw](https://github.com/openclaw/openclaw). -โšก๏ธ Delivers core agent functionality with **99% fewer lines of code** than OpenClaw, making it more customizable and understandable. +โšก๏ธ Delivers core agent functionality with **99% fewer lines of code** than OpenClaw. ๐Ÿ“ Real-time line count: run `bash core_agent_lines.sh` to verify anytime. From 0343d66224007d6d7964984db7741ae710c81167 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 5 Mar 2026 14:54:53 +0000 Subject: [PATCH 74/84] fix(gateway): remove duplicate load_config() that overwrote custom workspace/config --- nanobot/cli/commands.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 05e2cbe24..b09705958 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -244,7 +244,7 @@ def _make_provider(config: Config): @app.command() def gateway( port: int = typer.Option(18790, "--port", "-p", help="Gateway port"), - workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory (default: ~/.nanobot/workspace)"), + workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"), config: str | None = typer.Option(None, "--config", "-c", help="Config file path"), verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"), ): @@ -252,7 +252,7 @@ def gateway( from nanobot.agent.loop import AgentLoop from nanobot.bus.queue import MessageBus from nanobot.channels.manager import ChannelManager - from nanobot.config.loader import get_data_dir, load_config + from nanobot.config.loader import load_config from nanobot.cron.service import CronService from nanobot.cron.types import CronJob from nanobot.heartbeat.service import HeartbeatService @@ -262,17 +262,12 @@ def gateway( import logging logging.basicConfig(level=logging.DEBUG) - # Load config from custom path if provided, otherwise use default config_path = Path(config) if config else None config = load_config(config_path) - - # Override workspace if specified via command line if workspace: config.agents.defaults.workspace = workspace console.print(f"{__logo__} Starting nanobot gateway on port {port}...") - - config = load_config() sync_workspace_templates(config.workspace_path) bus = MessageBus() provider = _make_provider(config) From d32c6f946c5fd030ddfbbb645adb43b84a43d6ed Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 5 Mar 2026 15:17:30 +0000 Subject: [PATCH 75/84] fix(telegram): pin ptb>=22.6, fix double progress, clean up stale hatch config --- nanobot/agent/loop.py | 14 ++++++-------- nanobot/channels/telegram.py | 2 +- pyproject.toml | 5 +---- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 2f6a2bc91..7f129a2f4 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -205,18 +205,16 @@ class AgentLoop: thoughts = [ self._strip_think(response.content), response.reasoning_content, - *(f"Thinking [{b.get('signature', '...')}]:\n{b.get('thought', '...')}" - for b in (response.thinking_blocks or []) if isinstance(b, dict) and "signature" in b) + *( + f"Thinking [{b.get('signature', '...')}]:\n{b.get('thought', '...')}" + for b in (response.thinking_blocks or []) + if isinstance(b, dict) and "signature" in b + ), ] - combined_thoughts = "\n\n".join(filter(None, thoughts)) - tool_hint_str = self._tool_hint(response.tool_calls) - if combined_thoughts: await on_progress(combined_thoughts) - await on_progress(f"{combined_thoughts}\n\n{tool_hint_str}", tool_hint=True) - else: - await on_progress(tool_hint_str, tool_hint=True) + await on_progress(self._tool_hint(response.tool_calls), tool_hint=True) tool_call_dicts = [ { diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index de95a15f5..884b2d0a5 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -292,7 +292,7 @@ class TelegramChannel(BaseChannel): reply_parameters=reply_params ) except Exception as e: - logger.warning("HTML parse failed (or draft send failed), falling back to plain text: {}", e) + logger.warning("HTML parse failed, falling back to plain text: {}", e) try: if is_progress and draft_id: await self._app.bot.send_message_draft( diff --git a/pyproject.toml b/pyproject.toml index 674a1efbd..41d0fbbfa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ dependencies = [ "rich>=14.0.0,<15.0.0", "croniter>=6.0.0,<7.0.0", "dingtalk-stream>=0.24.0,<1.0.0", - "python-telegram-bot[socks]>=22.0,<23.0", + "python-telegram-bot[socks]>=22.6,<23.0", "lark-oapi>=1.5.0,<2.0.0", "socksio>=1.0.0,<2.0.0", "python-socketio>=5.16.0,<6.0.0", @@ -68,9 +68,6 @@ nanobot = "nanobot.cli.commands:app" requires = ["hatchling"] build-backend = "hatchling.build" -[tool.hatch.metadata] -allow-direct-references = true - [tool.hatch.build.targets.wheel] packages = ["nanobot"] From 5ced08b1f23f5ef275465fbe3140f64d42c95ced Mon Sep 17 00:00:00 2001 From: pikaqqqqqq Date: Fri, 6 Mar 2026 01:54:00 +0800 Subject: [PATCH 76/84] fix(feishu): use msg_type "media" for mp4 video files Previously, mp4 video files were sent with msg_type "file", which meant users had to download them to play. Feishu requires msg_type "media" for audio and video files to enable inline playback in the chat. Changes: - Add _VIDEO_EXTS constant for video file extensions (.mp4, .mov, .avi) - Use msg_type "media" for both audio (_AUDIO_EXTS) and video (_VIDEO_EXTS) - Keep msg_type "file" for documents and other file types The upload_file API already uses file_type="mp4" for video files via the existing _FILE_TYPE_MAP, so only the send msg_type needed fixing. --- nanobot/channels/feishu.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index e6f0049da..3847ac1c0 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -474,6 +474,7 @@ class FeishuChannel(BaseChannel): _IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"} _AUDIO_EXTS = {".opus"} + _VIDEO_EXTS = {".mp4", ".mov", ".avi"} _FILE_TYPE_MAP = { ".opus": "opus", ".mp4": "mp4", ".pdf": "pdf", ".doc": "doc", ".docx": "doc", ".xls": "xls", ".xlsx": "xls", ".ppt": "ppt", ".pptx": "ppt", @@ -682,7 +683,12 @@ class FeishuChannel(BaseChannel): else: key = await loop.run_in_executor(None, self._upload_file_sync, file_path) if key: - media_type = "audio" if ext in self._AUDIO_EXTS else "file" + # Use msg_type "media" for audio/video so users can play inline; + # "file" for everything else (documents, archives, etc.) + if ext in self._AUDIO_EXTS or ext in self._VIDEO_EXTS: + media_type = "media" + else: + media_type = "file" await loop.run_in_executor( None, self._send_message_sync, receive_id_type, msg.chat_id, media_type, json.dumps({"file_key": key}, ensure_ascii=False), From 9ab4155991627e45dd2c88b028d35c55b82ecce9 Mon Sep 17 00:00:00 2001 From: nanobot-contributor Date: Fri, 6 Mar 2026 09:57:03 +0800 Subject: [PATCH 77/84] fix(cli): add Windows compatibility for signal handlers (PR #1400) SIGHUP and SIGPIPE are not available on Windows. Add hasattr() checks before registering these signal handlers to prevent AttributeError on Windows systems. Fixes compatibility issue introduced in PR #1400. --- nanobot/cli/commands.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index aca0778de..eb3d83329 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -7,6 +7,18 @@ import signal import sys from pathlib import Path +# Force UTF-8 encoding for Windows console +if sys.platform == "win32": + import locale + if sys.stdout.encoding != "utf-8": + os.environ["PYTHONIOENCODING"] = "utf-8" + # Re-open stdout/stderr with UTF-8 encoding + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + import typer from prompt_toolkit import PromptSession from prompt_toolkit.formatted_text import HTML @@ -525,9 +537,13 @@ def agent( signal.signal(signal.SIGINT, _handle_signal) signal.signal(signal.SIGTERM, _handle_signal) - signal.signal(signal.SIGHUP, _handle_signal) + # SIGHUP is not available on Windows + if hasattr(signal, 'SIGHUP'): + signal.signal(signal.SIGHUP, _handle_signal) # Ignore SIGPIPE to prevent silent process termination when writing to closed pipes - signal.signal(signal.SIGPIPE, signal.SIG_IGN) + # SIGPIPE is not available on Windows + if hasattr(signal, 'SIGPIPE'): + signal.signal(signal.SIGPIPE, signal.SIG_IGN) async def run_interactive(): bus_task = asyncio.create_task(agent_loop.run()) From c3526a7fdb2418d68c03d34db5ee43b624edbce9 Mon Sep 17 00:00:00 2001 From: PiKaqqqqqq <281705236@qq.com> Date: Fri, 6 Mar 2026 10:11:53 +0800 Subject: [PATCH 78/84] fix(feishu): smart message format selection (fixes #1548) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of always sending interactive cards, detect the optimal message format based on content: - text: short plain text (โ‰ค200 chars, no markdown) - post: medium text with links (โ‰ค2000 chars) - interactive: complex content (code, tables, headings, bold, lists) --- nanobot/channels/feishu.py | 143 +++++++++++++++++++++++++++++++++++-- pr-description.md | 47 ++++++++++++ 2 files changed, 186 insertions(+), 4 deletions(-) create mode 100644 pr-description.md diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index e6f0049da..c40549383 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -472,6 +472,121 @@ class FeishuChannel(BaseChannel): return elements or [{"tag": "markdown", "content": content}] + # โ”€โ”€ Smart format detection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # Patterns that indicate "complex" markdown needing card rendering + _COMPLEX_MD_RE = re.compile( + r"```" # fenced code block + r"|^\|.+\|.*\n\s*\|[-:\s|]+\|" # markdown table (header + separator) + r"|^#{1,6}\s+" # headings + , re.MULTILINE, + ) + + # Simple markdown patterns (bold, italic, strikethrough) + _SIMPLE_MD_RE = re.compile( + r"\*\*.+?\*\*" # **bold** + r"|__.+?__" # __bold__ + r"|(? str: + """Determine the optimal Feishu message format for *content*. + + Returns one of: + - ``"text"`` โ€“ plain text, short and no markdown + - ``"post"`` โ€“ rich text (links only, moderate length) + - ``"interactive"`` โ€“ card with full markdown rendering + """ + stripped = content.strip() + + # Complex markdown (code blocks, tables, headings) โ†’ always card + if cls._COMPLEX_MD_RE.search(stripped): + return "interactive" + + # Long content โ†’ card (better readability with card layout) + if len(stripped) > cls._POST_MAX_LEN: + return "interactive" + + # Has bold/italic/strikethrough โ†’ card (post format can't render these) + if cls._SIMPLE_MD_RE.search(stripped): + return "interactive" + + # Has list items โ†’ card (post format can't render list bullets well) + if cls._LIST_RE.search(stripped) or cls._OLIST_RE.search(stripped): + return "interactive" + + # Has links โ†’ post format (supports tags) + if cls._MD_LINK_RE.search(stripped): + return "post" + + # Short plain text โ†’ text format + if len(stripped) <= cls._TEXT_MAX_LEN: + return "text" + + # Medium plain text without any formatting โ†’ post format + return "post" + + @classmethod + def _markdown_to_post(cls, content: str) -> str: + """Convert markdown content to Feishu post message JSON. + + Handles links ``[text](url)`` as ``a`` tags; everything else as ``text`` tags. + Each line becomes a paragraph (row) in the post body. + """ + lines = content.strip().split("\n") + paragraphs: list[list[dict]] = [] + + for line in lines: + elements: list[dict] = [] + last_end = 0 + + for m in cls._MD_LINK_RE.finditer(line): + # Text before this link + before = line[last_end:m.start()] + if before: + elements.append({"tag": "text", "text": before}) + elements.append({ + "tag": "a", + "text": m.group(1), + "href": m.group(2), + }) + last_end = m.end() + + # Remaining text after last link + remaining = line[last_end:] + if remaining: + elements.append({"tag": "text", "text": remaining}) + + # Empty line โ†’ empty paragraph for spacing + if not elements: + elements.append({"tag": "text", "text": ""}) + + paragraphs.append(elements) + + post_body = { + "zh_cn": { + "content": paragraphs, + } + } + return json.dumps(post_body, ensure_ascii=False) + _IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"} _AUDIO_EXTS = {".opus"} _FILE_TYPE_MAP = { @@ -689,14 +804,34 @@ class FeishuChannel(BaseChannel): ) if msg.content and msg.content.strip(): - elements = self._build_card_elements(msg.content) - for chunk in self._split_elements_by_table_limit(elements): - card = {"config": {"wide_screen_mode": True}, "elements": chunk} + fmt = self._detect_msg_format(msg.content) + + if fmt == "text": + # Short plain text โ€“ send as simple text message + text_body = json.dumps({"text": msg.content.strip()}, ensure_ascii=False) await loop.run_in_executor( None, self._send_message_sync, - receive_id_type, msg.chat_id, "interactive", json.dumps(card, ensure_ascii=False), + receive_id_type, msg.chat_id, "text", text_body, ) + elif fmt == "post": + # Medium content with links โ€“ send as rich-text post + post_body = self._markdown_to_post(msg.content) + await loop.run_in_executor( + None, self._send_message_sync, + receive_id_type, msg.chat_id, "post", post_body, + ) + + else: + # Complex / long content โ€“ send as interactive card + elements = self._build_card_elements(msg.content) + for chunk in self._split_elements_by_table_limit(elements): + card = {"config": {"wide_screen_mode": True}, "elements": chunk} + await loop.run_in_executor( + None, self._send_message_sync, + receive_id_type, msg.chat_id, "interactive", json.dumps(card, ensure_ascii=False), + ) + except Exception as e: logger.error("Error sending Feishu message: {}", e) diff --git a/pr-description.md b/pr-description.md new file mode 100644 index 000000000..dacab5c6a --- /dev/null +++ b/pr-description.md @@ -0,0 +1,47 @@ +## fix(feishu): smart message format selection (fixes #1548) + +### Problem + +Currently, the Feishu channel sends **all** messages as interactive cards (`msg_type: "interactive"`). This is overkill for short, simple replies like "OK" or "ๆ”ถๅˆฐ" โ€” they look heavy and unnatural compared to normal chat messages. + +### Solution + +Implement smart message format selection that picks the most appropriate Feishu message type based on content analysis: + +| Content Type | Format | `msg_type` | +|---|---|---| +| Short plain text (โ‰ค 200 chars, no markdown) | Text | `text` | +| Medium text with links (โ‰ค 2000 chars, no complex formatting) | Rich Text Post | `post` | +| Long text, code blocks, tables, headings, bold/italic, lists | Interactive Card | `interactive` | + +### How it works + +1. **`_detect_msg_format(content)`** โ€” Analyzes the message content and returns the optimal format: + - Checks for complex markdown (code blocks, tables, headings) โ†’ `interactive` + - Checks for simple markdown (bold, italic, lists) โ†’ `interactive` + - Checks for links โ†’ `post` (Feishu post format supports `` tags natively) + - Short plain text โ†’ `text` + - Medium plain text โ†’ `post` + +2. **`_markdown_to_post(content)`** โ€” Converts markdown links `[text](url)` to Feishu post format with proper `a` tags. Each line becomes a paragraph in the post body. + +3. **Modified `send()` method** โ€” Uses `_detect_msg_format()` to choose the right format, then dispatches to the appropriate sending logic. + +### Design decisions + +- **Post format for links only**: Feishu's post format (`[[{"tag":"text",...}]]`) doesn't support bold/italic rendering, so we only use it for messages containing links (where the `a` tag adds real value). Messages with bold/italic/lists still use cards which render markdown properly. +- **Conservative thresholds**: 200 chars for text, 2000 chars for post โ€” these keep the UX natural without being too aggressive. +- **Backward compatible**: The card rendering path is completely unchanged. Only the routing logic is new. + +### Testing + +Format detection tested against 13 cases covering all content types: +- โœ… Plain text โ†’ `text` +- โœ… Links โ†’ `post` +- โœ… Bold/italic/code/tables/headings/lists โ†’ `interactive` +- โœ… Long content โ†’ `interactive` +- โœ… Post format generates valid Feishu post JSON with proper `a` tags + +### Changes + +- `nanobot/channels/feishu.py`: Added `_detect_msg_format()`, `_markdown_to_post()`, and updated `send()` method From 6fb4204ac6a5109a4ff068a17975615498c40c05 Mon Sep 17 00:00:00 2001 From: nanobot-contributor Date: Fri, 6 Mar 2026 11:47:00 +0800 Subject: [PATCH 79/84] fix(memory): handle list type tool call arguments Some LLM providers return tool_calls[0].arguments as a list instead of dict or str. Add handling to extract the first dict element from the list. Fixes /new command warning: 'unexpected arguments type list' --- nanobot/agent/memory.py | 7 +++ tests/test_memory_consolidation_types.py | 75 ++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index 93c18258a..80fba5ec9 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -128,6 +128,13 @@ class MemoryStore: # Some providers return arguments as a JSON string instead of dict if isinstance(args, str): args = json.loads(args) + # Some providers return arguments as a list (handle edge case) + if isinstance(args, list): + if args and isinstance(args[0], dict): + args = args[0] + else: + logger.warning("Memory consolidation: unexpected arguments type list with non-dict content") + return False if not isinstance(args, dict): logger.warning("Memory consolidation: unexpected arguments type {}", type(args).__name__) return False diff --git a/tests/test_memory_consolidation_types.py b/tests/test_memory_consolidation_types.py index 375c802df..ff15584f7 100644 --- a/tests/test_memory_consolidation_types.py +++ b/tests/test_memory_consolidation_types.py @@ -145,3 +145,78 @@ class TestMemoryConsolidationTypeHandling: assert result is True provider.chat.assert_not_called() + + @pytest.mark.asyncio + async def test_list_arguments_extracts_first_dict(self, tmp_path: Path) -> None: + """Some providers return arguments as a list - extract first element if it's a dict.""" + store = MemoryStore(tmp_path) + provider = AsyncMock() + + # Simulate arguments being a list containing a dict + response = LLMResponse( + content=None, + tool_calls=[ + ToolCallRequest( + id="call_1", + name="save_memory", + arguments=[{ + "history_entry": "[2026-01-01] User discussed testing.", + "memory_update": "# Memory\nUser likes testing.", + }], + ) + ], + ) + provider.chat = AsyncMock(return_value=response) + session = _make_session(message_count=60) + + result = await store.consolidate(session, provider, "test-model", memory_window=50) + + assert result is True + assert "User discussed testing." in store.history_file.read_text() + assert "User likes testing." in store.memory_file.read_text() + + @pytest.mark.asyncio + async def test_list_arguments_empty_list_returns_false(self, tmp_path: Path) -> None: + """Empty list arguments should return False.""" + store = MemoryStore(tmp_path) + provider = AsyncMock() + + response = LLMResponse( + content=None, + tool_calls=[ + ToolCallRequest( + id="call_1", + name="save_memory", + arguments=[], + ) + ], + ) + provider.chat = AsyncMock(return_value=response) + session = _make_session(message_count=60) + + result = await store.consolidate(session, provider, "test-model", memory_window=50) + + assert result is False + + @pytest.mark.asyncio + async def test_list_arguments_non_dict_content_returns_false(self, tmp_path: Path) -> None: + """List with non-dict content should return False.""" + store = MemoryStore(tmp_path) + provider = AsyncMock() + + response = LLMResponse( + content=None, + tool_calls=[ + ToolCallRequest( + id="call_1", + name="save_memory", + arguments=["string", "content"], + ) + ], + ) + provider.chat = AsyncMock(return_value=response) + session = _make_session(message_count=60) + + result = await store.consolidate(session, provider, "test-model", memory_window=50) + + assert result is False From fc0b38c3047c20241c94b38f1be6138191da41f6 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 6 Mar 2026 05:27:39 +0000 Subject: [PATCH 80/84] fix(memory): improve warning message for empty/non-dict list arguments --- nanobot/agent/memory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index 80fba5ec9..21fe77da5 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -133,7 +133,7 @@ class MemoryStore: if args and isinstance(args[0], dict): args = args[0] else: - logger.warning("Memory consolidation: unexpected arguments type list with non-dict content") + logger.warning("Memory consolidation: unexpected arguments as empty or non-dict list") return False if not isinstance(args, dict): logger.warning("Memory consolidation: unexpected arguments type {}", type(args).__name__) From ba63f6f62d9b2181b56863d8efe32215fe8f6321 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 6 Mar 2026 06:09:46 +0000 Subject: [PATCH 81/84] chore: remove pr-description.md from repo --- pr-description.md | 47 ----------------------------------------------- 1 file changed, 47 deletions(-) delete mode 100644 pr-description.md diff --git a/pr-description.md b/pr-description.md deleted file mode 100644 index dacab5c6a..000000000 --- a/pr-description.md +++ /dev/null @@ -1,47 +0,0 @@ -## fix(feishu): smart message format selection (fixes #1548) - -### Problem - -Currently, the Feishu channel sends **all** messages as interactive cards (`msg_type: "interactive"`). This is overkill for short, simple replies like "OK" or "ๆ”ถๅˆฐ" โ€” they look heavy and unnatural compared to normal chat messages. - -### Solution - -Implement smart message format selection that picks the most appropriate Feishu message type based on content analysis: - -| Content Type | Format | `msg_type` | -|---|---|---| -| Short plain text (โ‰ค 200 chars, no markdown) | Text | `text` | -| Medium text with links (โ‰ค 2000 chars, no complex formatting) | Rich Text Post | `post` | -| Long text, code blocks, tables, headings, bold/italic, lists | Interactive Card | `interactive` | - -### How it works - -1. **`_detect_msg_format(content)`** โ€” Analyzes the message content and returns the optimal format: - - Checks for complex markdown (code blocks, tables, headings) โ†’ `interactive` - - Checks for simple markdown (bold, italic, lists) โ†’ `interactive` - - Checks for links โ†’ `post` (Feishu post format supports `` tags natively) - - Short plain text โ†’ `text` - - Medium plain text โ†’ `post` - -2. **`_markdown_to_post(content)`** โ€” Converts markdown links `[text](url)` to Feishu post format with proper `a` tags. Each line becomes a paragraph in the post body. - -3. **Modified `send()` method** โ€” Uses `_detect_msg_format()` to choose the right format, then dispatches to the appropriate sending logic. - -### Design decisions - -- **Post format for links only**: Feishu's post format (`[[{"tag":"text",...}]]`) doesn't support bold/italic rendering, so we only use it for messages containing links (where the `a` tag adds real value). Messages with bold/italic/lists still use cards which render markdown properly. -- **Conservative thresholds**: 200 chars for text, 2000 chars for post โ€” these keep the UX natural without being too aggressive. -- **Backward compatible**: The card rendering path is completely unchanged. Only the routing logic is new. - -### Testing - -Format detection tested against 13 cases covering all content types: -- โœ… Plain text โ†’ `text` -- โœ… Links โ†’ `post` -- โœ… Bold/italic/code/tables/headings/lists โ†’ `interactive` -- โœ… Long content โ†’ `interactive` -- โœ… Post format generates valid Feishu post JSON with proper `a` tags - -### Changes - -- `nanobot/channels/feishu.py`: Added `_detect_msg_format()`, `_markdown_to_post()`, and updated `send()` method From 3a01fe536a37c8424fc196b1b0aad3535a50af93 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 6 Mar 2026 06:49:09 +0000 Subject: [PATCH 82/84] refactor: move detect_image_mime to utils/helpers for reuse --- nanobot/agent/context.py | 16 ++-------------- nanobot/utils/helpers.py | 13 +++++++++++++ 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 7ead317f3..27511fa91 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -10,19 +10,7 @@ from typing import Any from nanobot.agent.memory import MemoryStore from nanobot.agent.skills import SkillsLoader - - -def _detect_image_mime(data: bytes) -> str | None: - """Detect image MIME type from magic bytes, ignoring file extension.""" - if data[:8] == b"\x89PNG\r\n\x1a\n": - return "image/png" - if data[:3] == b"\xff\xd8\xff": - return "image/jpeg" - if data[:6] in (b"GIF87a", b"GIF89a"): - return "image/gif" - if data[:4] == b"RIFF" and data[8:12] == b"WEBP": - return "image/webp" - return None +from nanobot.utils.helpers import detect_image_mime class ContextBuilder: @@ -153,7 +141,7 @@ Reply directly with text for conversations. Only use the 'message' tool to send continue raw = p.read_bytes() # Detect real MIME type from magic bytes; fallback to filename guess - mime = _detect_image_mime(raw) or mimetypes.guess_type(path)[0] + mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0] if not mime or not mime.startswith("image/"): continue b64 = base64.b64encode(raw).decode() diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index 3a8c8023f..b54317408 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -5,6 +5,19 @@ from datetime import datetime from pathlib import Path +def detect_image_mime(data: bytes) -> str | None: + """Detect image MIME type from magic bytes, ignoring file extension.""" + if data[:8] == b"\x89PNG\r\n\x1a\n": + return "image/png" + if data[:3] == b"\xff\xd8\xff": + return "image/jpeg" + if data[:6] in (b"GIF87a", b"GIF89a"): + return "image/gif" + if data[:4] == b"RIFF" and data[8:12] == b"WEBP": + return "image/webp" + return None + + def ensure_dir(path: Path) -> Path: """Ensure directory exists, return it.""" path.mkdir(parents=True, exist_ok=True) From b817463939c9529ab119fec1c7be89dd2da68606 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 6 Mar 2026 07:13:04 +0000 Subject: [PATCH 83/84] chore: simplify Alibaba Coding Plan to apiBase hint, remove dedicated provider --- README.md | 2 +- nanobot/config/schema.py | 28 +++------------------------- nanobot/providers/registry.py | 20 -------------------- 3 files changed, 4 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 627bb37a5..0c4960876 100644 --- a/README.md +++ b/README.md @@ -662,9 +662,9 @@ Config file: `~/.nanobot/config.json` > [!TIP] > - **Groq** provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed. > - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config. -> - **Alibaba Cloud Coding Plan**: If you're on the Alibaba Cloud Coding Plan (BaiLian coding assistance), add configuration for `dashscope_coding_plan` provider with an API key starting with `sk-sp-` in your config. This provider uses OpenAI-compatible endpoint `https://coding.dashscope.aliyuncs.com/v1`. > - **MiniMax (Mainland China)**: If your API key is from MiniMax's mainland China platform (minimaxi.com), set `"apiBase": "https://api.minimaxi.com/v1"` in your minimax provider config. > - **VolcEngine Coding Plan**: If you're on VolcEngine's coding plan, set `"apiBase": "https://ark.cn-beijing.volces.com/api/coding/v3"` in your volcengine provider config. +> - **Alibaba Cloud Coding Plan**: If you're on the Alibaba Cloud Coding Plan (BaiLian), set `"apiBase": "https://coding.dashscope.aliyuncs.com/v1"` in your dashscope provider config. | Provider | Purpose | Get API Key | |----------|---------|-------------| diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index e80c8d039..2073eeb07 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -5,7 +5,7 @@ from typing import Literal from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings import BaseSettings class Base(BaseModel): @@ -258,20 +258,13 @@ class ProvidersConfig(Base): groq: ProviderConfig = Field(default_factory=ProviderConfig) zhipu: ProviderConfig = Field(default_factory=ProviderConfig) dashscope: ProviderConfig = Field(default_factory=ProviderConfig) # ้˜ฟ้‡Œไบ‘้€šไน‰ๅƒ้—ฎ - dashscope_coding_plan: ProviderConfig = Field( - default_factory=ProviderConfig - ) # ้˜ฟ้‡Œไบ‘็™พ็‚ผCoding Plan vllm: ProviderConfig = Field(default_factory=ProviderConfig) gemini: ProviderConfig = Field(default_factory=ProviderConfig) moonshot: ProviderConfig = Field(default_factory=ProviderConfig) minimax: ProviderConfig = Field(default_factory=ProviderConfig) aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway - siliconflow: ProviderConfig = Field( - default_factory=ProviderConfig - ) # SiliconFlow (็ก…ๅŸบๆตๅŠจ) API gateway - volcengine: ProviderConfig = Field( - default_factory=ProviderConfig - ) # VolcEngine (็ซๅฑฑๅผ•ๆ“Ž) API gateway + siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (็ก…ๅŸบๆตๅŠจ) + volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (็ซๅฑฑๅผ•ๆ“Ž) openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth) github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth) @@ -326,20 +319,6 @@ class MCPServerConfig(Base): tool_timeout: int = 30 # seconds before a tool call is cancelled -class TTSConfig(Base): - """Text-to-Speech configuration.""" - - provider: str = "edge_tts" # Default TTS provider - voice: str = "en-US-ChristopherNeural" # Default voice - speed: float = 1.0 # Voice speed multiplier - - -class AudioConfig(Base): - """Audio configuration.""" - - tts: TTSConfig = Field(default_factory=TTSConfig) - - class ToolsConfig(Base): """Tools configuration.""" @@ -357,7 +336,6 @@ class Config(BaseSettings): providers: ProvidersConfig = Field(default_factory=ProvidersConfig) gateway: GatewayConfig = Field(default_factory=GatewayConfig) tools: ToolsConfig = Field(default_factory=ToolsConfig) - audio: AudioConfig = Field(default_factory=AudioConfig) @property def workspace_path(self) -> Path: diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 3b6659e10..59ba31a8f 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -350,26 +350,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( strip_model_prefix=False, model_overrides=(), ), - # === Coding Plan Gateway Providers ===================================== - # Alibaba Cloud Coding Plan: OpenAI-compatible gateway for coding assistance. - # Uses special API key format starting with "sk-sp-" to distinguish it - # from regular dashscope keys. Uses the OpenAI-compatible endpoint. - ProviderSpec( - name="dashscope_coding_plan", - keywords=("dashscope-coding-plan", "coding-plan", "aliyun-coding", "bailian-coding"), - env_key="DASHSCOPE_CODING_PLAN_API_KEY", - display_name="Alibaba Cloud Coding Plan", - litellm_prefix="openai", # โ†’ openai/{model} (uses OpenAI-compatible endpoint) - skip_prefixes=("openai/", "dashscope/", "openrouter/"), - env_extras=(), - is_gateway=True, - is_local=False, - detect_by_key_prefix="sk-sp-", # coding plan API keys start with "sk-sp-" - detect_by_base_keyword="coding.dashscope", - default_api_base="https://coding.dashscope.aliyuncs.com/v1", - strip_model_prefix=True, # Strip "dashscope_coding_plan/" prefix - model_overrides=(), - ), # === Auxiliary (not a primary LLM provider) ============================ # Groq: mainly used for Whisper voice transcription, also usable for LLM. # Needs "groq/" prefix for LiteLLM routing. Placed last โ€” it rarely wins fallback. From dcebb94b014cd80dd93e80acad0664cf708ae767 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 6 Mar 2026 07:16:20 +0000 Subject: [PATCH 84/84] style: remove trailing whitespace --- 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 a1819a29d..620424e61 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -298,7 +298,7 @@ class LiteLLMProvider(LLMProvider): reasoning_content = getattr(message, "reasoning_content", None) or None thinking_blocks = getattr(message, "thinking_blocks", None) or None - + return LLMResponse( content=content, tool_calls=tool_calls,