From cf2ed8a6a011bad6bf25f182682b913b6664be38 Mon Sep 17 00:00:00 2001 From: gaoyiman Date: Thu, 26 Feb 2026 16:22:24 +0800 Subject: [PATCH 01/21] tune volcengine provider --- nanobot/config/schema.py | 5 +++- nanobot/providers/registry.py | 56 ++++++++++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 61aee96..d2866ff 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -244,7 +244,10 @@ class ProvidersConfig(Base): 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 + volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎) pay-per-use + volcengine_plan: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine Coding Plan + byteplus: ProviderConfig = Field(default_factory=ProviderConfig) # BytePlus (火山引擎海外版) pay-per-use + byteplus_plan: ProviderConfig = Field(default_factory=ProviderConfig) # BytePlus Coding Plan openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth) github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth) diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 2766929..28d9b26 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -141,7 +141,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( model_overrides=(), ), - # VolcEngine (火山引擎): OpenAI-compatible gateway + # VolcEngine (火山引擎): OpenAI-compatible gateway, pay-per-use models ProviderSpec( name="volcengine", keywords=("volcengine", "volces", "ark"), @@ -159,6 +159,60 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( model_overrides=(), ), + # VolcEngine Coding Plan (火山引擎 Coding Plan): same key as volcengine + ProviderSpec( + name="volcengine_plan", + keywords=("volcengine-plan",), + env_key="OPENAI_API_KEY", + display_name="VolcEngine Coding Plan", + litellm_prefix="volcengine", + skip_prefixes=(), + env_extras=(), + is_gateway=True, + is_local=False, + detect_by_key_prefix="", + detect_by_base_keyword="", + default_api_base="https://ark.cn-beijing.volces.com/api/coding/v3", + strip_model_prefix=True, + model_overrides=(), + ), + + # BytePlus: VolcEngine international, pay-per-use models + ProviderSpec( + name="byteplus", + keywords=("byteplus",), + env_key="OPENAI_API_KEY", + display_name="BytePlus", + litellm_prefix="volcengine", + skip_prefixes=(), + env_extras=(), + is_gateway=True, + is_local=False, + detect_by_key_prefix="", + detect_by_base_keyword="bytepluses", + default_api_base="https://ark.ap-southeast.bytepluses.com/api/v3", + strip_model_prefix=True, + model_overrides=(), + ), + + # BytePlus Coding Plan: same key as byteplus + ProviderSpec( + name="byteplus_plan", + keywords=("byteplus-plan",), + env_key="OPENAI_API_KEY", + display_name="BytePlus Coding Plan", + litellm_prefix="volcengine", + skip_prefixes=(), + env_extras=(), + is_gateway=True, + is_local=False, + detect_by_key_prefix="", + detect_by_base_keyword="", + default_api_base="https://ark.ap-southeast.bytepluses.com/api/coding/v3", + strip_model_prefix=True, + model_overrides=(), + ), + # === Standard providers (matched by model-name keywords) =============== # Anthropic: LiteLLM recognizes "claude-*" natively, no prefix needed. From 0d60acf2d5c5f7f91d082249f9e70f3a77a0bbc1 Mon Sep 17 00:00:00 2001 From: gaoyiman Date: Thu, 5 Mar 2026 14:40:18 +0800 Subject: [PATCH 02/21] fix(schema): rename volcengine_plan and byteplus_plan to *_coding_plan for consistency --- nanobot/config/schema.py | 4 ++-- nanobot/providers/registry.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 718fd8b..8fc75d5 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -262,9 +262,9 @@ class ProvidersConfig(Base): 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 (火山引擎) pay-per-use - volcengine_plan: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine Coding Plan + volcengine_coding_plan: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine Coding Plan byteplus: ProviderConfig = Field(default_factory=ProviderConfig) # BytePlus (火山引擎海外版) pay-per-use - byteplus_plan: ProviderConfig = Field(default_factory=ProviderConfig) # BytePlus Coding Plan + byteplus_coding_plan: ProviderConfig = Field(default_factory=ProviderConfig) # BytePlus Coding Plan openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth) github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth) diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 2cd743e..1c80506 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -161,7 +161,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( # VolcEngine Coding Plan (火山引擎 Coding Plan): same key as volcengine ProviderSpec( - name="volcengine_plan", + name="volcengine_coding_plan", keywords=("volcengine-plan",), env_key="OPENAI_API_KEY", display_name="VolcEngine Coding Plan", @@ -197,7 +197,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( # BytePlus Coding Plan: same key as byteplus ProviderSpec( - name="byteplus_plan", + name="byteplus_coding_plan", keywords=("byteplus-plan",), env_key="OPENAI_API_KEY", display_name="BytePlus Coding Plan", From 711903bc5fd00be72009c0b04ab1e42d46239311 Mon Sep 17 00:00:00 2001 From: Zek Date: Mon, 9 Mar 2026 17:54:02 +0800 Subject: [PATCH 03/21] feat(feishu): add global group mention policy - Add group_policy config: 'open' (default) or 'mention' - 'open': Respond to all group messages (backward compatible) - 'mention': Only respond when @mentioned in any group - Auto-detect bot mentions by pattern matching: * If open_id configured: match against mentions * Otherwise: detect bot by empty user_id + ou_ open_id pattern - Support @_all mentions - Private chats unaffected (always respond) - Clean implementation with minimal logging docs: update Feishu README with group policy documentation --- README.md | 15 +++++++- nanobot/channels/feishu.py | 78 ++++++++++++++++++++++++++++++++++++++ nanobot/config/schema.py | 2 + 3 files changed, 94 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f169bd7..29221a7 100644 --- a/README.md +++ b/README.md @@ -482,7 +482,8 @@ Uses **WebSocket** long connection — no public IP required. "appSecret": "xxx", "encryptKey": "", "verificationToken": "", - "allowFrom": ["ou_YOUR_OPEN_ID"] + "allowFrom": ["ou_YOUR_OPEN_ID"], + "groupPolicy": "open" } } } @@ -491,6 +492,18 @@ Uses **WebSocket** long connection — no public IP required. > `encryptKey` and `verificationToken` are optional for Long Connection mode. > `allowFrom`: Add your open_id (find it in nanobot logs when you message the bot). Use `["*"]` to allow all users. +**Group Chat Policy** (optional): + +| Option | Values | Default | Description | +|--------|--------|---------|-------------| +| `groupPolicy` | `"open"` | `"open"` | Respond to all group messages (backward compatible) | +| | `"mention"` | | Only respond when @mentioned | + +> [!NOTE] +> - `"open"`: Respond to all messages in all groups +> - `"mention"`: Only respond when @mentioned in any group +> - Private chats are unaffected (always respond) + **3. Run** ```bash diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index a637025..78bf2df 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -352,6 +352,74 @@ class FeishuChannel(BaseChannel): self._running = False logger.info("Feishu bot stopped") + def _get_bot_open_id_sync(self) -> str | None: + """Get bot's own open_id for mention detection. + + 飞书 SDK 没有直接的 bot info API,从配置或缓存获取。 + """ + # 尝试从配置获取 open_id(用户可以在配置中指定) + if hasattr(self.config, 'open_id') and self.config.open_id: + return self.config.open_id + + return None + + def _is_bot_mentioned(self, message: Any, bot_open_id: str | None) -> bool: + """Check if bot is mentioned in the message. + + 飞书 mentions 数组包含被@的对象。匹配策略: + 1. 如果配置了 bot_open_id,则匹配 open_id + 2. 否则,检查 mentions 中是否有空的 user_id(bot 的特征) + + Handles: + - Direct mentions in message.mentions + - @all mentions + """ + # Check @all + raw_content = message.content or "" + if "@_all" in raw_content: + logger.debug("Feishu: @_all mention detected") + return True + + # Check mentions array + mentions = message.mentions if hasattr(message, 'mentions') and message.mentions else [] + if mentions: + if bot_open_id: + # 策略 1: 匹配配置的 open_id + for mention in mentions: + if mention.id: + open_id = getattr(mention.id, 'open_id', None) + if open_id == bot_open_id: + logger.debug("Feishu: bot mention matched") + return True + else: + # 策略 2: 检查 bot 特征 - user_id 为空且 open_id 存在 + for mention in mentions: + if mention.id: + user_id = getattr(mention.id, 'user_id', None) + open_id = getattr(mention.id, 'open_id', None) + # Bot 的特征:user_id 为空字符串,open_id 存在 + if user_id == '' and open_id and open_id.startswith('ou_'): + logger.debug("Feishu: bot mention matched") + return True + + return False + + def _should_respond_in_group( + self, + chat_id: str, + mentioned: bool + ) -> tuple[bool, str]: + """Determine if bot should respond in a group chat. + + Returns: + (should_respond, reason) + """ + # Check mention requirement + if self.config.group_policy == "mention" and not mentioned: + return False, "not mentioned in group" + + return True, "" + 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 @@ -892,6 +960,16 @@ class FeishuChannel(BaseChannel): chat_type = message.chat_type msg_type = message.message_type + # Check group policy and mention requirement + if chat_type == "group": + bot_open_id = self._get_bot_open_id_sync() + mentioned = self._is_bot_mentioned(message, bot_open_id) + should_respond, reason = self._should_respond_in_group(chat_id, mentioned) + + if not should_respond: + logger.debug("Feishu: ignoring group message - {}", reason) + return + # Add reaction await self._add_reaction(message_id, self.config.react_emoji) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 803cb61..6b2eb35 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -47,6 +47,8 @@ class FeishuConfig(Base): react_emoji: str = ( "THUMBSUP" # Emoji type for message reactions (e.g. THUMBSUP, OK, DONE, SMILE) ) + # Group chat settings + group_policy: Literal["open", "mention"] = "open" # Group response policy (default: open for backward compatibility) class DingTalkConfig(Base): From b24d6ffc941f7ff755898fa94485bab51e4415d4 Mon Sep 17 00:00:00 2001 From: shenchengtsi Date: Tue, 10 Mar 2026 11:32:11 +0800 Subject: [PATCH 04/21] fix(memory): validate save_memory payload before persisting --- nanobot/agent/memory.py | 33 ++++++--- tests/test_memory_consolidation_types.py | 94 +++++++++++++++++++++++- 2 files changed, 116 insertions(+), 11 deletions(-) diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index 21fe77d..add014b 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -139,15 +139,30 @@ class MemoryStore: logger.warning("Memory consolidation: unexpected arguments type {}", type(args).__name__) return False - if entry := args.get("history_entry"): - if not isinstance(entry, str): - entry = json.dumps(entry, ensure_ascii=False) - self.append_history(entry) - if update := args.get("memory_update"): - if not isinstance(update, str): - update = json.dumps(update, ensure_ascii=False) - if update != current_memory: - self.write_long_term(update) + if "history_entry" not in args or "memory_update" not in args: + logger.warning("Memory consolidation: save_memory payload missing required fields") + return False + + entry = args["history_entry"] + update = args["memory_update"] + + if entry is None or update is None: + logger.warning("Memory consolidation: save_memory payload contains null required fields") + return False + + if not isinstance(entry, str): + entry = json.dumps(entry, ensure_ascii=False) + if not isinstance(update, str): + update = json.dumps(update, ensure_ascii=False) + + entry = entry.strip() + if not entry: + logger.warning("Memory consolidation: history_entry is empty after normalization") + return False + + self.append_history(entry) + if update != current_memory: + self.write_long_term(update) session.last_consolidated = 0 if archive_all else len(session.messages) - keep_count logger.info("Memory consolidation done: {} messages, last_consolidated={}", len(session.messages), session.last_consolidated) diff --git a/tests/test_memory_consolidation_types.py b/tests/test_memory_consolidation_types.py index ff15584..4ba1ecd 100644 --- a/tests/test_memory_consolidation_types.py +++ b/tests/test_memory_consolidation_types.py @@ -97,7 +97,6 @@ class TestMemoryConsolidationTypeHandling: store = MemoryStore(tmp_path) provider = AsyncMock() - # Simulate arguments being a JSON string (not yet parsed) response = LLMResponse( content=None, tool_calls=[ @@ -152,7 +151,6 @@ class TestMemoryConsolidationTypeHandling: store = MemoryStore(tmp_path) provider = AsyncMock() - # Simulate arguments being a list containing a dict response = LLMResponse( content=None, tool_calls=[ @@ -220,3 +218,95 @@ class TestMemoryConsolidationTypeHandling: result = await store.consolidate(session, provider, "test-model", memory_window=50) assert result is False + + @pytest.mark.asyncio + async def test_missing_history_entry_returns_false_without_writing(self, tmp_path: Path) -> None: + """Do not persist partial results when required fields are missing.""" + store = MemoryStore(tmp_path) + provider = AsyncMock() + provider.chat = AsyncMock( + return_value=LLMResponse( + content=None, + tool_calls=[ + ToolCallRequest( + id="call_1", + name="save_memory", + arguments={"memory_update": "# Memory\nOnly memory update"}, + ) + ], + ) + ) + session = _make_session(message_count=60) + + result = await store.consolidate(session, provider, "test-model", memory_window=50) + + assert result is False + assert not store.history_file.exists() + assert not store.memory_file.exists() + assert session.last_consolidated == 0 + + @pytest.mark.asyncio + async def test_missing_memory_update_returns_false_without_writing(self, tmp_path: Path) -> None: + """Do not append history if memory_update is missing.""" + store = MemoryStore(tmp_path) + provider = AsyncMock() + provider.chat = AsyncMock( + return_value=LLMResponse( + content=None, + tool_calls=[ + ToolCallRequest( + id="call_1", + name="save_memory", + arguments={"history_entry": "[2026-01-01] Partial output."}, + ) + ], + ) + ) + session = _make_session(message_count=60) + + result = await store.consolidate(session, provider, "test-model", memory_window=50) + + assert result is False + assert not store.history_file.exists() + assert not store.memory_file.exists() + assert session.last_consolidated == 0 + + @pytest.mark.asyncio + async def test_null_required_field_returns_false_without_writing(self, tmp_path: Path) -> None: + """Null required fields should be rejected before persistence.""" + store = MemoryStore(tmp_path) + provider = AsyncMock() + provider.chat = AsyncMock( + return_value=_make_tool_response( + history_entry=None, + memory_update="# Memory\nUser likes testing.", + ) + ) + session = _make_session(message_count=60) + + result = await store.consolidate(session, provider, "test-model", memory_window=50) + + assert result is False + assert not store.history_file.exists() + assert not store.memory_file.exists() + assert session.last_consolidated == 0 + + @pytest.mark.asyncio + async def test_empty_history_entry_returns_false_without_writing(self, tmp_path: Path) -> None: + """Empty history entries should be rejected to avoid blank archival records.""" + store = MemoryStore(tmp_path) + provider = AsyncMock() + provider.chat = AsyncMock( + return_value=_make_tool_response( + history_entry=" ", + memory_update="# Memory\nUser likes testing.", + ) + ) + session = _make_session(message_count=60) + + result = await store.consolidate(session, provider, "test-model", memory_window=50) + + assert result is False + assert not store.history_file.exists() + assert not store.memory_file.exists() + assert session.last_consolidated == 0 From 1eedee0c405123115ace30e400c38370a0a27846 Mon Sep 17 00:00:00 2001 From: John Doe Date: Thu, 12 Mar 2026 06:23:02 +0700 Subject: [PATCH 05/21] add reply context extraction for Telegram messages --- nanobot/channels/telegram.py | 40 +++++++++++++++- tests/test_telegram_channel.py | 86 +++++++++++++++++++++++++++++++++- 2 files changed, 124 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 9f93843..ccb1518 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -20,6 +20,7 @@ from nanobot.config.schema import TelegramConfig from nanobot.utils.helpers import split_message TELEGRAM_MAX_MESSAGE_LEN = 4000 # Telegram message character limit +TELEGRAM_REPLY_CONTEXT_MAX_LEN = TELEGRAM_MAX_MESSAGE_LEN # Max length for reply context in user message def _strip_md(s: str) -> str: @@ -451,6 +452,7 @@ class TelegramChannel(BaseChannel): @staticmethod def _build_message_metadata(message, user) -> dict: """Build common Telegram inbound metadata payload.""" + reply_to = getattr(message, "reply_to_message", None) return { "message_id": message.message_id, "user_id": user.id, @@ -459,8 +461,37 @@ class TelegramChannel(BaseChannel): "is_group": message.chat.type != "private", "message_thread_id": getattr(message, "message_thread_id", None), "is_forum": bool(getattr(message.chat, "is_forum", False)), + "reply_to_message_id": getattr(reply_to, "message_id", None) if reply_to else None, } + @staticmethod + def _extract_reply_context(message) -> str | None: + """Extract content from the message being replied to, if any. Truncated to TELEGRAM_REPLY_CONTEXT_MAX_LEN.""" + reply = getattr(message, "reply_to_message", None) + if not reply: + return None + text = getattr(reply, "text", None) or getattr(reply, "caption", None) + if text: + truncated = ( + text[:TELEGRAM_REPLY_CONTEXT_MAX_LEN] + + ("..." if len(text) > TELEGRAM_REPLY_CONTEXT_MAX_LEN else "") + ) + return f"[Reply to: {truncated}]" + # Reply has no text/caption; use type placeholder when it has media + if getattr(reply, "photo", None): + return "[Reply to: (image)]" + if getattr(reply, "document", None): + return "[Reply to: (document)]" + if getattr(reply, "voice", None): + return "[Reply to: (voice)]" + if getattr(reply, "video_note", None) or getattr(reply, "video", None): + return "[Reply to: (video)]" + if getattr(reply, "audio", None): + return "[Reply to: (audio)]" + if getattr(reply, "animation", None): + return "[Reply to: (animation)]" + return "[Reply to: (no text)]" + async def _ensure_bot_identity(self) -> tuple[int | None, str | None]: """Load bot identity once and reuse it for mention/reply checks.""" if self._bot_user_id is not None or self._bot_username is not None: @@ -542,10 +573,14 @@ class TelegramChannel(BaseChannel): message = update.message user = update.effective_user self._remember_thread_context(message) + reply_ctx = self._extract_reply_context(message) + content = message.text or "" + if reply_ctx: + content = reply_ctx + "\n\n" + content await self._handle_message( sender_id=self._sender_id(user), chat_id=str(message.chat_id), - content=message.text, + content=content, metadata=self._build_message_metadata(message, user), session_key=self._derive_topic_session_key(message), ) @@ -625,6 +660,9 @@ class TelegramChannel(BaseChannel): logger.error("Failed to download media: {}", e) content_parts.append(f"[{media_type}: download failed]") + reply_ctx = self._extract_reply_context(message) + if reply_ctx is not None: + content_parts.insert(0, reply_ctx) content = "\n".join(content_parts) if content_parts else "[empty message]" logger.debug("Telegram message from {}: {}...", sender_id, content[:50]) diff --git a/tests/test_telegram_channel.py b/tests/test_telegram_channel.py index 678512d..30b9e4f 100644 --- a/tests/test_telegram_channel.py +++ b/tests/test_telegram_channel.py @@ -1,10 +1,11 @@ +import asyncio from types import SimpleNamespace import pytest from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus -from nanobot.channels.telegram import TelegramChannel +from nanobot.channels.telegram import TELEGRAM_REPLY_CONTEXT_MAX_LEN, TelegramChannel from nanobot.config.schema import TelegramConfig @@ -336,3 +337,86 @@ async def test_group_policy_open_accepts_plain_group_message() -> None: assert len(handled) == 1 assert channel._app.bot.get_me_calls == 0 + + +def test_extract_reply_context_no_reply() -> None: + """When there is no reply_to_message, _extract_reply_context returns None.""" + message = SimpleNamespace(reply_to_message=None) + assert TelegramChannel._extract_reply_context(message) is None + + +def test_extract_reply_context_with_text() -> None: + """When reply has text, return prefixed string.""" + reply = SimpleNamespace(text="Hello world", caption=None) + message = SimpleNamespace(reply_to_message=reply) + assert TelegramChannel._extract_reply_context(message) == "[Reply to: Hello world]" + + +def test_extract_reply_context_with_caption_only() -> None: + """When reply has only caption (no text), caption is used.""" + reply = SimpleNamespace(text=None, caption="Photo caption") + message = SimpleNamespace(reply_to_message=reply) + assert TelegramChannel._extract_reply_context(message) == "[Reply to: Photo caption]" + + +def test_extract_reply_context_truncation() -> None: + """Reply text is truncated at TELEGRAM_REPLY_CONTEXT_MAX_LEN.""" + long_text = "x" * (TELEGRAM_REPLY_CONTEXT_MAX_LEN + 100) + reply = SimpleNamespace(text=long_text, caption=None) + message = SimpleNamespace(reply_to_message=reply) + result = TelegramChannel._extract_reply_context(message) + assert result is not None + assert result.startswith("[Reply to: ") + assert result.endswith("...]") + assert len(result) == len("[Reply to: ]") + TELEGRAM_REPLY_CONTEXT_MAX_LEN + len("...") + + +def test_extract_reply_context_no_text_no_media() -> None: + """When reply has no text/caption and no media, return (no text) placeholder.""" + reply = SimpleNamespace( + text=None, + caption=None, + photo=None, + document=None, + voice=None, + video_note=None, + video=None, + audio=None, + animation=None, + ) + message = SimpleNamespace(reply_to_message=reply) + assert TelegramChannel._extract_reply_context(message) == "[Reply to: (no text)]" + + +def test_extract_reply_context_reply_to_photo() -> None: + """When reply has photo but no text/caption, return (image) placeholder.""" + reply = SimpleNamespace( + text=None, + caption=None, + photo=[SimpleNamespace(file_id="x")], + ) + message = SimpleNamespace(reply_to_message=reply) + assert TelegramChannel._extract_reply_context(message) == "[Reply to: (image)]" + + +@pytest.mark.asyncio +async def test_on_message_includes_reply_context() -> None: + """When user replies to a message, content passed to bus starts with reply context.""" + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"), + MessageBus(), + ) + channel._app = _FakeApp(lambda: None) + handled = [] + async def capture_handle(**kwargs) -> None: + handled.append(kwargs) + channel._handle_message = capture_handle + channel._start_typing = lambda _chat_id: None + + reply = SimpleNamespace(text="Hello", message_id=2, from_user=SimpleNamespace(id=1)) + update = _make_telegram_update(text="translate this", reply_to_message=reply) + await channel._on_message(update, None) + + assert len(handled) == 1 + assert handled[0]["content"].startswith("[Reply to: Hello]") + assert "translate this" in handled[0]["content"] From 3f799531cc0df2f00fe0241b4203b56dbb75fa80 Mon Sep 17 00:00:00 2001 From: John Doe Date: Thu, 12 Mar 2026 06:43:59 +0700 Subject: [PATCH 06/21] Add media download functionality --- nanobot/channels/telegram.py | 133 ++++++++++++++++++-------------- tests/test_telegram_channel.py | 134 ++++++++++++++++++++++++++++++++- 2 files changed, 210 insertions(+), 57 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index ccb1518..6f4422a 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -477,21 +477,75 @@ class TelegramChannel(BaseChannel): + ("..." if len(text) > TELEGRAM_REPLY_CONTEXT_MAX_LEN else "") ) return f"[Reply to: {truncated}]" - # Reply has no text/caption; use type placeholder when it has media + # Reply has no text/caption; use type placeholder when it has media. + # Note: replied-to media is not attached to this message, so the agent won't receive it. if getattr(reply, "photo", None): - return "[Reply to: (image)]" + return "[Reply to: (image — not attached)]" if getattr(reply, "document", None): - return "[Reply to: (document)]" + return "[Reply to: (document — not attached)]" if getattr(reply, "voice", None): - return "[Reply to: (voice)]" + return "[Reply to: (voice — not attached)]" if getattr(reply, "video_note", None) or getattr(reply, "video", None): - return "[Reply to: (video)]" + return "[Reply to: (video — not attached)]" if getattr(reply, "audio", None): - return "[Reply to: (audio)]" + return "[Reply to: (audio — not attached)]" if getattr(reply, "animation", None): - return "[Reply to: (animation)]" + return "[Reply to: (animation — not attached)]" return "[Reply to: (no text)]" + async def _download_message_media( + self, msg, *, add_failure_content: bool = False + ) -> tuple[list[str], list[str]]: + """Download media from a message (current or reply). Returns (media_paths, content_parts).""" + media_file = None + media_type = None + if getattr(msg, "photo", None): + media_file = msg.photo[-1] + media_type = "image" + elif getattr(msg, "voice", None): + media_file = msg.voice + media_type = "voice" + elif getattr(msg, "audio", None): + media_file = msg.audio + media_type = "audio" + elif getattr(msg, "document", None): + media_file = msg.document + media_type = "file" + elif getattr(msg, "video", None): + media_file = msg.video + media_type = "video" + elif getattr(msg, "video_note", None): + media_file = msg.video_note + media_type = "video" + elif getattr(msg, "animation", None): + media_file = msg.animation + media_type = "animation" + if not media_file or not self._app: + return [], [] + try: + file = await self._app.bot.get_file(media_file.file_id) + ext = self._get_extension( + media_type, + getattr(media_file, "mime_type", None), + getattr(media_file, "file_name", None), + ) + media_dir = get_media_dir("telegram") + file_path = media_dir / f"{media_file.file_id[:16]}{ext}" + await file.download_to_drive(str(file_path)) + path_str = str(file_path) + if media_type in ("voice", "audio"): + transcription = await self.transcribe_audio(file_path) + if transcription: + logger.info("Transcribed {}: {}...", media_type, transcription[:50]) + return [path_str], [f"[transcription: {transcription}]"] + return [path_str], [f"[{media_type}: {path_str}]"] + return [path_str], [f"[{media_type}: {path_str}]"] + except Exception as e: + logger.warning("Failed to download message media: {}", e) + if add_failure_content: + return [], [f"[{media_type}: download failed]"] + return [], [] + async def _ensure_bot_identity(self) -> tuple[int | None, str | None]: """Load bot identity once and reuse it for mention/reply checks.""" if self._bot_user_id is not None or self._bot_username is not None: @@ -612,56 +666,25 @@ class TelegramChannel(BaseChannel): 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" - elif message.voice: - media_file = message.voice - media_type = "voice" - elif message.audio: - media_file = message.audio - media_type = "audio" - 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), - getattr(media_file, 'file_name', None), - ) - media_dir = get_media_dir("telegram") - - 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)) - - if media_type in ("voice", "audio"): - transcription = await self.transcribe_audio(file_path) - if transcription: - logger.info("Transcribed {}: {}...", media_type, transcription[:50]) - content_parts.append(f"[transcription: {transcription}]") - else: - 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]") + # Download current message media + current_media_paths, current_media_parts = await self._download_message_media( + message, add_failure_content=True + ) + media_paths.extend(current_media_paths) + content_parts.extend(current_media_parts) + if current_media_paths: + logger.debug("Downloaded message media to {}", current_media_paths[0]) + # Reply context: include replied-to content; if reply has media, try to attach it + reply = getattr(message, "reply_to_message", None) reply_ctx = self._extract_reply_context(message) - if reply_ctx is not None: + if reply_ctx is not None and reply is not None: + if "not attached)]" in reply_ctx: + reply_media_paths, reply_media_parts = await self._download_message_media(reply) + if reply_media_paths and reply_media_parts: + reply_ctx = f"[Reply to: {reply_media_parts[0]}]" + media_paths = reply_media_paths + media_paths + logger.debug("Attached replied-to media: {}", reply_media_paths[0]) content_parts.insert(0, reply_ctx) content = "\n".join(content_parts) if content_parts else "[empty message]" diff --git a/tests/test_telegram_channel.py b/tests/test_telegram_channel.py index 30b9e4f..75824ac 100644 --- a/tests/test_telegram_channel.py +++ b/tests/test_telegram_channel.py @@ -1,5 +1,7 @@ import asyncio +from pathlib import Path from types import SimpleNamespace +from unittest.mock import AsyncMock import pytest @@ -43,6 +45,12 @@ class _FakeBot: async def send_chat_action(self, **kwargs) -> None: pass + async def get_file(self, file_id: str): + """Return a fake file that 'downloads' to a path (for reply-to-media tests).""" + async def _fake_download(path) -> None: + pass + return SimpleNamespace(download_to_drive=_fake_download) + class _FakeApp: def __init__(self, on_start_polling) -> None: @@ -389,14 +397,14 @@ def test_extract_reply_context_no_text_no_media() -> None: def test_extract_reply_context_reply_to_photo() -> None: - """When reply has photo but no text/caption, return (image) placeholder.""" + """When reply has photo but no text/caption, return (image — not attached) placeholder.""" reply = SimpleNamespace( text=None, caption=None, photo=[SimpleNamespace(file_id="x")], ) message = SimpleNamespace(reply_to_message=reply) - assert TelegramChannel._extract_reply_context(message) == "[Reply to: (image)]" + assert TelegramChannel._extract_reply_context(message) == "[Reply to: (image — not attached)]" @pytest.mark.asyncio @@ -420,3 +428,125 @@ async def test_on_message_includes_reply_context() -> None: assert len(handled) == 1 assert handled[0]["content"].startswith("[Reply to: Hello]") assert "translate this" in handled[0]["content"] + + +@pytest.mark.asyncio +async def test_download_message_media_returns_path_when_download_succeeds( + monkeypatch, tmp_path +) -> None: + """_download_message_media returns (paths, content_parts) when bot.get_file and download succeed.""" + media_dir = tmp_path / "media" / "telegram" + media_dir.mkdir(parents=True) + monkeypatch.setattr( + "nanobot.channels.telegram.get_media_dir", + lambda channel=None: media_dir if channel else tmp_path / "media", + ) + + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]), + MessageBus(), + ) + channel._app = _FakeApp(lambda: None) + channel._app.bot.get_file = AsyncMock( + return_value=SimpleNamespace(download_to_drive=AsyncMock(return_value=None)) + ) + + msg = SimpleNamespace( + photo=[SimpleNamespace(file_id="fid123", mime_type="image/jpeg")], + voice=None, + audio=None, + document=None, + video=None, + video_note=None, + animation=None, + ) + paths, parts = await channel._download_message_media(msg) + assert len(paths) == 1 + assert len(parts) == 1 + assert "fid123" in paths[0] + assert "[image:" in parts[0] + + +@pytest.mark.asyncio +async def test_on_message_attaches_reply_to_media_when_available(monkeypatch, tmp_path) -> None: + """When user replies to a message with media, that media is downloaded and attached to the turn.""" + media_dir = tmp_path / "media" / "telegram" + media_dir.mkdir(parents=True) + monkeypatch.setattr( + "nanobot.channels.telegram.get_media_dir", + lambda channel=None: media_dir if channel else tmp_path / "media", + ) + + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"), + MessageBus(), + ) + app = _FakeApp(lambda: None) + app.bot.get_file = AsyncMock( + return_value=SimpleNamespace(download_to_drive=AsyncMock(return_value=None)) + ) + channel._app = app + handled = [] + async def capture_handle(**kwargs) -> None: + handled.append(kwargs) + channel._handle_message = capture_handle + channel._start_typing = lambda _chat_id: None + + reply_with_photo = SimpleNamespace( + text=None, + caption=None, + photo=[SimpleNamespace(file_id="reply_photo_fid", mime_type="image/jpeg")], + document=None, + voice=None, + audio=None, + video=None, + video_note=None, + animation=None, + ) + update = _make_telegram_update( + text="what is the image?", + reply_to_message=reply_with_photo, + ) + await channel._on_message(update, None) + + assert len(handled) == 1 + assert handled[0]["content"].startswith("[Reply to: [image:") + assert "what is the image?" in handled[0]["content"] + assert len(handled[0]["media"]) == 1 + assert "reply_photo_fid" in handled[0]["media"][0] + + +@pytest.mark.asyncio +async def test_on_message_reply_to_media_fallback_when_download_fails() -> None: + """When reply has media but download fails, keep placeholder and do not attach.""" + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"), + MessageBus(), + ) + channel._app = _FakeApp(lambda: None) + # No get_file on bot -> download will fail + channel._app.bot.get_file = None + handled = [] + async def capture_handle(**kwargs) -> None: + handled.append(kwargs) + channel._handle_message = capture_handle + channel._start_typing = lambda _chat_id: None + + reply_with_photo = SimpleNamespace( + text=None, + caption=None, + photo=[SimpleNamespace(file_id="x", mime_type="image/jpeg")], + document=None, + voice=None, + audio=None, + video=None, + video_note=None, + animation=None, + ) + update = _make_telegram_update(text="what is this?", reply_to_message=reply_with_photo) + await channel._on_message(update, None) + + assert len(handled) == 1 + assert "[Reply to: (image — not attached)]" in handled[0]["content"] + assert "what is this?" in handled[0]["content"] + assert handled[0]["media"] == [] From bd1ce8f1440311d42dcc22c60153964f64d27a94 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 12 Mar 2026 04:45:57 +0000 Subject: [PATCH 07/21] Simplify feishu group_policy: default to mention, clean up mention detection --- README.md | 15 +------ nanobot/channels/feishu.py | 91 ++++++++------------------------------ nanobot/config/schema.py | 3 +- 3 files changed, 22 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index 155920f..dccb4be 100644 --- a/README.md +++ b/README.md @@ -503,7 +503,7 @@ Uses **WebSocket** long connection — no public IP required. "encryptKey": "", "verificationToken": "", "allowFrom": ["ou_YOUR_OPEN_ID"], - "groupPolicy": "open" + "groupPolicy": "mention" } } } @@ -511,18 +511,7 @@ Uses **WebSocket** long connection — no public IP required. > `encryptKey` and `verificationToken` are optional for Long Connection mode. > `allowFrom`: Add your open_id (find it in nanobot logs when you message the bot). Use `["*"]` to allow all users. - -**Group Chat Policy** (optional): - -| Option | Values | Default | Description | -|--------|--------|---------|-------------| -| `groupPolicy` | `"open"` | `"open"` | Respond to all group messages (backward compatible) | -| | `"mention"` | | Only respond when @mentioned | - -> [!NOTE] -> - `"open"`: Respond to all messages in all groups -> - `"mention"`: Only respond when @mentioned in any group -> - Private chats are unaffected (always respond) +> `groupPolicy`: `"mention"` (default — respond only when @mentioned), `"open"` (respond to all group messages). Private chats always respond. **3. Run** diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 4919e3c..780227a 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -352,73 +352,26 @@ class FeishuChannel(BaseChannel): self._running = False logger.info("Feishu bot stopped") - def _get_bot_open_id_sync(self) -> str | None: - """Get bot's own open_id for mention detection. - - 飞书 SDK 没有直接的 bot info API,从配置或缓存获取。 - """ - # 尝试从配置获取 open_id(用户可以在配置中指定) - if hasattr(self.config, 'open_id') and self.config.open_id: - return self.config.open_id - - return None - - def _is_bot_mentioned(self, message: Any, bot_open_id: str | None) -> bool: - """Check if bot is mentioned in the message. - - 飞书 mentions 数组包含被@的对象。匹配策略: - 1. 如果配置了 bot_open_id,则匹配 open_id - 2. 否则,检查 mentions 中是否有空的 user_id(bot 的特征) - - Handles: - - Direct mentions in message.mentions - - @all mentions - """ - # Check @all + def _is_bot_mentioned(self, message: Any) -> bool: + """Check if the bot is @mentioned in the message.""" raw_content = message.content or "" if "@_all" in raw_content: - logger.debug("Feishu: @_all mention detected") return True - - # Check mentions array - mentions = message.mentions if hasattr(message, 'mentions') and message.mentions else [] - if mentions: - if bot_open_id: - # 策略 1: 匹配配置的 open_id - for mention in mentions: - if mention.id: - open_id = getattr(mention.id, 'open_id', None) - if open_id == bot_open_id: - logger.debug("Feishu: bot mention matched") - return True - else: - # 策略 2: 检查 bot 特征 - user_id 为空且 open_id 存在 - for mention in mentions: - if mention.id: - user_id = getattr(mention.id, 'user_id', None) - open_id = getattr(mention.id, 'open_id', None) - # Bot 的特征:user_id 为空字符串,open_id 存在 - if user_id == '' and open_id and open_id.startswith('ou_'): - logger.debug("Feishu: bot mention matched") - return True - + + for mention in getattr(message, "mentions", None) or []: + mid = getattr(mention, "id", None) + if not mid: + continue + # Bot mentions have an empty user_id with a valid open_id + if getattr(mid, "user_id", None) == "" and (getattr(mid, "open_id", None) or "").startswith("ou_"): + return True return False - def _should_respond_in_group( - self, - chat_id: str, - mentioned: bool - ) -> tuple[bool, str]: - """Determine if bot should respond in a group chat. - - Returns: - (should_respond, reason) - """ - # Check mention requirement - if self.config.group_policy == "mention" and not mentioned: - return False, "not mentioned in group" - - return True, "" + def _is_group_message_for_bot(self, message: Any) -> bool: + """Allow group messages when policy is open or bot is @mentioned.""" + if self.config.group_policy == "open": + return True + return self._is_bot_mentioned(message) def _add_reaction_sync(self, message_id: str, emoji_type: str) -> None: """Sync helper for adding reaction (runs in thread pool).""" @@ -961,16 +914,10 @@ class FeishuChannel(BaseChannel): chat_type = message.chat_type msg_type = message.message_type - # Check group policy and mention requirement - if chat_type == "group": - bot_open_id = self._get_bot_open_id_sync() - mentioned = self._is_bot_mentioned(message, bot_open_id) - should_respond, reason = self._should_respond_in_group(chat_id, mentioned) - - if not should_respond: - logger.debug("Feishu: ignoring group message - {}", reason) - return - + if chat_type == "group" and not self._is_group_message_for_bot(message): + logger.debug("Feishu: skipping group message (not mentioned)") + return + # Add reaction await self._add_reaction(message_id, self.config.react_emoji) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 592a93c..55e109e 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -48,8 +48,7 @@ class FeishuConfig(Base): react_emoji: str = ( "THUMBSUP" # Emoji type for message reactions (e.g. THUMBSUP, OK, DONE, SMILE) ) - # Group chat settings - group_policy: Literal["open", "mention"] = "open" # Group response policy (default: open for backward compatibility) + group_policy: Literal["open", "mention"] = "mention" # "mention" responds when @mentioned, "open" responds to all class DingTalkConfig(Base): From 6141b950377de035ce5b7ced244ae2047624c198 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 12 Mar 2026 06:00:39 +0000 Subject: [PATCH 08/21] =?UTF-8?q?fix:=20feishu=20bot=20mention=20detection?= =?UTF-8?q?=20=E2=80=94=20user=5Fid=20can=20be=20None,=20not=20just=20empt?= =?UTF-8?q?y=20string?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 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 780227a..2eb6a6a 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -362,8 +362,8 @@ class FeishuChannel(BaseChannel): mid = getattr(mention, "id", None) if not mid: continue - # Bot mentions have an empty user_id with a valid open_id - if getattr(mid, "user_id", None) == "" and (getattr(mid, "open_id", None) or "").startswith("ou_"): + # Bot mentions have no user_id (None or "") but a valid open_id + if not getattr(mid, "user_id", None) and (getattr(mid, "open_id", None) or "").startswith("ou_"): return True return False From 64888b4b09175bc41497d343802d352f522be3af Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 12 Mar 2026 06:16:57 +0000 Subject: [PATCH 09/21] Simplify reply context extraction, fix slash commands broken by reply injection, attach reply media regardless of caption --- nanobot/channels/telegram.py | 54 +++++------------ tests/test_telegram_channel.py | 103 ++++++++++++++++++++++++--------- 2 files changed, 91 insertions(+), 66 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 9373294..916685b 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -468,32 +468,14 @@ class TelegramChannel(BaseChannel): @staticmethod def _extract_reply_context(message) -> str | None: - """Extract content from the message being replied to, if any. Truncated to TELEGRAM_REPLY_CONTEXT_MAX_LEN.""" + """Extract text from the message being replied to, if any.""" reply = getattr(message, "reply_to_message", None) if not reply: return None - text = getattr(reply, "text", None) or getattr(reply, "caption", None) - if text: - truncated = ( - text[:TELEGRAM_REPLY_CONTEXT_MAX_LEN] - + ("..." if len(text) > TELEGRAM_REPLY_CONTEXT_MAX_LEN else "") - ) - return f"[Reply to: {truncated}]" - # Reply has no text/caption; use type placeholder when it has media. - # Note: replied-to media is not attached to this message, so the agent won't receive it. - if getattr(reply, "photo", None): - return "[Reply to: (image — not attached)]" - if getattr(reply, "document", None): - return "[Reply to: (document — not attached)]" - if getattr(reply, "voice", None): - return "[Reply to: (voice — not attached)]" - if getattr(reply, "video_note", None) or getattr(reply, "video", None): - return "[Reply to: (video — not attached)]" - if getattr(reply, "audio", None): - return "[Reply to: (audio — not attached)]" - if getattr(reply, "animation", None): - return "[Reply to: (animation — not attached)]" - return "[Reply to: (no text)]" + text = getattr(reply, "text", None) or getattr(reply, "caption", None) or "" + if len(text) > TELEGRAM_REPLY_CONTEXT_MAX_LEN: + text = text[:TELEGRAM_REPLY_CONTEXT_MAX_LEN] + "..." + return f"[Reply to: {text}]" if text else None async def _download_message_media( self, msg, *, add_failure_content: bool = False @@ -629,14 +611,10 @@ class TelegramChannel(BaseChannel): message = update.message user = update.effective_user self._remember_thread_context(message) - reply_ctx = self._extract_reply_context(message) - content = message.text or "" - if reply_ctx: - content = reply_ctx + "\n\n" + content await self._handle_message( sender_id=self._sender_id(user), chat_id=str(message.chat_id), - content=content, + content=message.text or "", metadata=self._build_message_metadata(message, user), session_key=self._derive_topic_session_key(message), ) @@ -677,17 +655,17 @@ class TelegramChannel(BaseChannel): if current_media_paths: logger.debug("Downloaded message media to {}", current_media_paths[0]) - # Reply context: include replied-to content; if reply has media, try to attach it + # Reply context: text and/or media from the replied-to message reply = getattr(message, "reply_to_message", None) - reply_ctx = self._extract_reply_context(message) - if reply_ctx is not None and reply is not None: - if "not attached)]" in reply_ctx: - reply_media_paths, reply_media_parts = await self._download_message_media(reply) - if reply_media_paths and reply_media_parts: - reply_ctx = f"[Reply to: {reply_media_parts[0]}]" - media_paths = reply_media_paths + media_paths - logger.debug("Attached replied-to media: {}", reply_media_paths[0]) - content_parts.insert(0, reply_ctx) + if reply is not None: + reply_ctx = self._extract_reply_context(message) + reply_media, reply_media_parts = await self._download_message_media(reply) + if reply_media: + media_paths = reply_media + media_paths + logger.debug("Attached replied-to media: {}", reply_media[0]) + tag = reply_ctx or (f"[Reply to: {reply_media_parts[0]}]" if reply_media_parts else None) + if tag: + content_parts.insert(0, tag) content = "\n".join(content_parts) if content_parts else "[empty message]" logger.debug("Telegram message from {}: {}...", sender_id, content[:50]) diff --git a/tests/test_telegram_channel.py b/tests/test_telegram_channel.py index 75824ac..897f77d 100644 --- a/tests/test_telegram_channel.py +++ b/tests/test_telegram_channel.py @@ -379,32 +379,11 @@ def test_extract_reply_context_truncation() -> None: assert len(result) == len("[Reply to: ]") + TELEGRAM_REPLY_CONTEXT_MAX_LEN + len("...") -def test_extract_reply_context_no_text_no_media() -> None: - """When reply has no text/caption and no media, return (no text) placeholder.""" - reply = SimpleNamespace( - text=None, - caption=None, - photo=None, - document=None, - voice=None, - video_note=None, - video=None, - audio=None, - animation=None, - ) +def test_extract_reply_context_no_text_returns_none() -> None: + """When reply has no text/caption, _extract_reply_context returns None (media handled separately).""" + reply = SimpleNamespace(text=None, caption=None) message = SimpleNamespace(reply_to_message=reply) - assert TelegramChannel._extract_reply_context(message) == "[Reply to: (no text)]" - - -def test_extract_reply_context_reply_to_photo() -> None: - """When reply has photo but no text/caption, return (image — not attached) placeholder.""" - reply = SimpleNamespace( - text=None, - caption=None, - photo=[SimpleNamespace(file_id="x")], - ) - message = SimpleNamespace(reply_to_message=reply) - assert TelegramChannel._extract_reply_context(message) == "[Reply to: (image — not attached)]" + assert TelegramChannel._extract_reply_context(message) is None @pytest.mark.asyncio @@ -518,13 +497,12 @@ async def test_on_message_attaches_reply_to_media_when_available(monkeypatch, tm @pytest.mark.asyncio async def test_on_message_reply_to_media_fallback_when_download_fails() -> None: - """When reply has media but download fails, keep placeholder and do not attach.""" + """When reply has media but download fails, no media attached and no reply tag.""" channel = TelegramChannel( TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"), MessageBus(), ) channel._app = _FakeApp(lambda: None) - # No get_file on bot -> download will fail channel._app.bot.get_file = None handled = [] async def capture_handle(**kwargs) -> None: @@ -547,6 +525,75 @@ async def test_on_message_reply_to_media_fallback_when_download_fails() -> None: await channel._on_message(update, None) assert len(handled) == 1 - assert "[Reply to: (image — not attached)]" in handled[0]["content"] assert "what is this?" in handled[0]["content"] assert handled[0]["media"] == [] + + +@pytest.mark.asyncio +async def test_on_message_reply_to_caption_and_media(monkeypatch, tmp_path) -> None: + """When replying to a message with caption + photo, both text context and media are included.""" + media_dir = tmp_path / "media" / "telegram" + media_dir.mkdir(parents=True) + monkeypatch.setattr( + "nanobot.channels.telegram.get_media_dir", + lambda channel=None: media_dir if channel else tmp_path / "media", + ) + + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"), + MessageBus(), + ) + app = _FakeApp(lambda: None) + app.bot.get_file = AsyncMock( + return_value=SimpleNamespace(download_to_drive=AsyncMock(return_value=None)) + ) + channel._app = app + handled = [] + async def capture_handle(**kwargs) -> None: + handled.append(kwargs) + channel._handle_message = capture_handle + channel._start_typing = lambda _chat_id: None + + reply_with_caption_and_photo = SimpleNamespace( + text=None, + caption="A cute cat", + photo=[SimpleNamespace(file_id="cat_fid", mime_type="image/jpeg")], + document=None, + voice=None, + audio=None, + video=None, + video_note=None, + animation=None, + ) + update = _make_telegram_update( + text="what breed is this?", + reply_to_message=reply_with_caption_and_photo, + ) + await channel._on_message(update, None) + + assert len(handled) == 1 + assert "[Reply to: A cute cat]" in handled[0]["content"] + assert "what breed is this?" in handled[0]["content"] + assert len(handled[0]["media"]) == 1 + assert "cat_fid" in handled[0]["media"][0] + + +@pytest.mark.asyncio +async def test_forward_command_does_not_inject_reply_context() -> None: + """Slash commands forwarded via _forward_command must not include reply context.""" + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"), + MessageBus(), + ) + channel._app = _FakeApp(lambda: None) + handled = [] + async def capture_handle(**kwargs) -> None: + handled.append(kwargs) + channel._handle_message = capture_handle + + reply = SimpleNamespace(text="some old message", message_id=2, from_user=SimpleNamespace(id=1)) + update = _make_telegram_update(text="/new", reply_to_message=reply) + await channel._forward_command(update, None) + + assert len(handled) == 1 + assert handled[0]["content"] == "/new" From 8e412b9603fad1dceff9ad085ff43e900e4fbf34 Mon Sep 17 00:00:00 2001 From: lvguangchuan001 Date: Thu, 12 Mar 2026 14:28:33 +0800 Subject: [PATCH 10/21] =?UTF-8?q?[=E7=B4=A7=E6=80=A5]=E4=BF=AE=E5=A4=8Dwe?= =?UTF-8?q?=5Fchat=E5=9C=A8pyproject.toml=E9=85=8D=E7=BD=AE=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a52c0c9..f9abdd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,13 +75,6 @@ build-backend = "hatchling.build" [tool.hatch.metadata] allow-direct-references = true -[tool.hatch.build.targets.wheel] -packages = ["nanobot"] - -[tool.hatch.build.targets.wheel.sources] -"nanobot" = "nanobot" - -# Include non-Python files in skills and templates [tool.hatch.build] include = [ "nanobot/**/*.py", @@ -90,6 +83,15 @@ include = [ "nanobot/skills/**/*.sh", ] +[tool.hatch.build.targets.wheel] +packages = ["nanobot"] + +[tool.hatch.build.targets.wheel.sources] +"nanobot" = "nanobot" + +[tool.hatch.build.targets.wheel.force-include] +"bridge" = "nanobot/bridge" + [tool.hatch.build.targets.sdist] include = [ "nanobot/", @@ -98,9 +100,6 @@ include = [ "LICENSE", ] -[tool.hatch.build.targets.wheel.force-include] -"bridge" = "nanobot/bridge" - [tool.ruff] line-length = 100 target-version = "py311" From 556cb3e83da2aeb240390e611ccc3a9638fa4235 Mon Sep 17 00:00:00 2001 From: gaoyiman Date: Thu, 12 Mar 2026 14:58:03 +0800 Subject: [PATCH 11/21] feat: add support for Ollama local models in ProvidersConfig --- nanobot/config/schema.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 3fd16ad..e985010 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -278,6 +278,7 @@ class ProvidersConfig(Base): zhipu: ProviderConfig = Field(default_factory=ProviderConfig) dashscope: ProviderConfig = Field(default_factory=ProviderConfig) # 阿里云通义千问 vllm: ProviderConfig = Field(default_factory=ProviderConfig) + ollama: ProviderConfig = Field(default_factory=ProviderConfig) # Ollama local models gemini: ProviderConfig = Field(default_factory=ProviderConfig) moonshot: ProviderConfig = Field(default_factory=ProviderConfig) minimax: ProviderConfig = Field(default_factory=ProviderConfig) From d51ec7f0e83abf8c346f3b775b2eadf7902b23c3 Mon Sep 17 00:00:00 2001 From: chengdu121 Date: Thu, 12 Mar 2026 19:15:04 +0800 Subject: [PATCH 12/21] fix: preserve interactive CLI formatting for async subagent output --- nanobot/cli/commands.py | 57 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index cf69450..332df74 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -19,10 +19,12 @@ if sys.platform == "win32": pass import typer +from prompt_toolkit import print_formatted_text from prompt_toolkit import PromptSession -from prompt_toolkit.formatted_text import HTML +from prompt_toolkit.formatted_text import ANSI, HTML from prompt_toolkit.history import FileHistory from prompt_toolkit.patch_stdout import patch_stdout +from prompt_toolkit.application import run_in_terminal from rich.console import Console from rich.markdown import Markdown from rich.table import Table @@ -111,8 +113,25 @@ def _init_prompt_session() -> None: ) +def _make_console() -> Console: + return Console(file=sys.stdout) + + +def _render_interactive_ansi(render_fn) -> str: + """Render Rich output to ANSI so prompt_toolkit can print it safely.""" + ansi_console = Console( + force_terminal=True, + color_system=console.color_system or "standard", + width=console.width, + ) + with ansi_console.capture() as capture: + render_fn(ansi_console) + return capture.get() + + def _print_agent_response(response: str, render_markdown: bool) -> None: """Render assistant response with consistent terminal styling.""" + console = _make_console() content = response or "" body = Markdown(content) if render_markdown else Text(content) console.print() @@ -121,6 +140,34 @@ def _print_agent_response(response: str, render_markdown: bool) -> None: console.print() +async def _print_interactive_line(text: str) -> None: + """Print async interactive updates with prompt_toolkit-safe Rich styling.""" + def _write() -> None: + ansi = _render_interactive_ansi( + lambda c: c.print(f" [dim]↳ {text}[/dim]") + ) + print_formatted_text(ANSI(ansi), end="") + + await run_in_terminal(_write) + + +async def _print_interactive_response(response: str, render_markdown: bool) -> None: + """Print async interactive replies with prompt_toolkit-safe Rich styling.""" + def _write() -> None: + content = response or "" + ansi = _render_interactive_ansi( + lambda c: ( + c.print(), + c.print(f"[cyan]{__logo__} nanobot[/cyan]"), + c.print(Markdown(content) if render_markdown else Text(content)), + c.print(), + ) + ) + print_formatted_text(ANSI(ansi), end="") + + await run_in_terminal(_write) + + def _is_exit_command(command: str) -> bool: """Return True when input should end interactive chat.""" return command.lower() in EXIT_COMMANDS @@ -611,14 +658,16 @@ def agent( elif ch and not is_tool_hint and not ch.send_progress: pass else: - console.print(f" [dim]↳ {msg.content}[/dim]") + #await _print_interactive_line(f" ↳ {msg.content}") + await _print_interactive_line(f" [dim]↳ {msg.content}[/dim]") + elif not turn_done.is_set(): if msg.content: turn_response.append(msg.content) turn_done.set() elif msg.content: - console.print() - _print_agent_response(msg.content, render_markdown=markdown) + await _print_interactive_response(msg.content, render_markdown=markdown) + except asyncio.TimeoutError: continue except asyncio.CancelledError: From 3467a7faa6291d41a81257faa36c6e7f5b9e71cc Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 12 Mar 2026 15:22:15 +0000 Subject: [PATCH 13/21] fix: improve local provider auto-selection and update docs for VolcEngine/BytePlus --- README.md | 5 +++-- nanobot/config/schema.py | 33 ++++++++++++++++----------------- tests/test_commands.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index dccb4be..629f59f 100644 --- a/README.md +++ b/README.md @@ -758,15 +758,17 @@ Config file: `~/.nanobot/config.json` > [!TIP] > - **Groq** provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed. +> - **VolcEngine / BytePlus Coding Plan**: Use dedicated providers `volcengineCodingPlan` or `byteplusCodingPlan` instead of the pay-per-use `volcengine` / `byteplus` providers. > - **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. > - **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 | |----------|---------|-------------| | `custom` | Any OpenAI-compatible endpoint (direct, no LiteLLM) | — | | `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | +| `volcengine` | LLM (VolcEngine, pay-per-use) | [Coding Plan](https://www.volcengine.com/activity/codingplan?utm_campaign=nanobot&utm_content=nanobot&utm_medium=devrel&utm_source=OWO&utm_term=nanobot) · [volcengine.com](https://www.volcengine.com) | +| `byteplus` | LLM (VolcEngine international, pay-per-use) | [Coding Plan](https://www.byteplus.com/en/activity/codingplan?utm_campaign=nanobot&utm_content=nanobot&utm_medium=devrel&utm_source=OWO&utm_term=nanobot) · [byteplus.com](https://www.byteplus.com) | | `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | | `azure_openai` | LLM (Azure OpenAI) | [portal.azure.com](https://portal.azure.com) | | `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | @@ -776,7 +778,6 @@ Config file: `~/.nanobot/config.json` | `minimax` | LLM (MiniMax direct) | [platform.minimaxi.com](https://platform.minimaxi.com) | | `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) | | `siliconflow` | LLM (SiliconFlow/硅基流动) | [siliconflow.cn](https://siliconflow.cn) | -| `volcengine` | LLM (VolcEngine/火山引擎) | [volcengine.com](https://www.volcengine.com) | | `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | | `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) | | `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) | diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index e985010..4092eeb 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -276,28 +276,18 @@ class ProvidersConfig(Base): deepseek: ProviderConfig = Field(default_factory=ProviderConfig) groq: ProviderConfig = Field(default_factory=ProviderConfig) zhipu: ProviderConfig = Field(default_factory=ProviderConfig) - dashscope: ProviderConfig = Field(default_factory=ProviderConfig) # 阿里云通义千问 + dashscope: ProviderConfig = Field(default_factory=ProviderConfig) vllm: ProviderConfig = Field(default_factory=ProviderConfig) ollama: ProviderConfig = Field(default_factory=ProviderConfig) # Ollama local models 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 - volcengine_coding_plan: ProviderConfig = Field( - default_factory=ProviderConfig - ) # VolcEngine Coding Plan (火山引擎 Coding Plan) - byteplus: ProviderConfig = Field( - default_factory=ProviderConfig - ) # BytePlus (VolcEngine international) - byteplus_coding_plan: ProviderConfig = Field( - default_factory=ProviderConfig - ) # BytePlus Coding Plan + siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动) + volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎) + volcengine_coding_plan: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine Coding Plan + byteplus: ProviderConfig = Field(default_factory=ProviderConfig) # BytePlus (VolcEngine international) + byteplus_coding_plan: ProviderConfig = Field(default_factory=ProviderConfig) # BytePlus Coding Plan openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth) github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth) @@ -411,12 +401,21 @@ class Config(BaseSettings): # Fallback: configured local providers can route models without # provider-specific keywords (for example plain "llama3.2" on Ollama). + # Prefer providers whose detect_by_base_keyword matches the configured api_base + # (e.g. Ollama's "11434" in "http://localhost:11434") over plain registry order. + local_fallback: tuple[ProviderConfig, str] | None = None for spec in PROVIDERS: if not spec.is_local: continue p = getattr(self.providers, spec.name, None) - if p and p.api_base: + if not (p and p.api_base): + continue + if spec.detect_by_base_keyword and spec.detect_by_base_keyword in p.api_base: return p, spec.name + if local_fallback is None: + local_fallback = (p, spec.name) + if local_fallback: + return local_fallback # Fallback: gateways first, then others (follows registry order) # OAuth providers are NOT valid fallbacks — they require explicit model selection diff --git a/tests/test_commands.py b/tests/test_commands.py index 583ef6f..5848bd8 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -143,6 +143,35 @@ def test_config_auto_detects_ollama_from_local_api_base(): assert config.get_api_base() == "http://localhost:11434" +def test_config_prefers_ollama_over_vllm_when_both_local_providers_configured(): + config = Config.model_validate( + { + "agents": {"defaults": {"provider": "auto", "model": "llama3.2"}}, + "providers": { + "vllm": {"apiBase": "http://localhost:8000"}, + "ollama": {"apiBase": "http://localhost:11434"}, + }, + } + ) + + assert config.get_provider_name() == "ollama" + assert config.get_api_base() == "http://localhost:11434" + + +def test_config_falls_back_to_vllm_when_ollama_not_configured(): + config = Config.model_validate( + { + "agents": {"defaults": {"provider": "auto", "model": "llama3.2"}}, + "providers": { + "vllm": {"apiBase": "http://localhost:8000"}, + }, + } + ) + + assert config.get_provider_name() == "vllm" + assert config.get_api_base() == "http://localhost:8000" + + def test_find_by_model_prefers_explicit_prefix_over_generic_codex_keyword(): spec = find_by_model("github-copilot/gpt-5.3-codex") From 3fa62e7fda39de1d785d1bc018a46a56fa3d2d9c Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 12 Mar 2026 15:38:39 +0000 Subject: [PATCH 14/21] fix: remove duplicate dim/arrow prefix in interactive progress line --- nanobot/cli/commands.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 91631ed..7cc4fd5 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -657,8 +657,7 @@ def agent( elif ch and not is_tool_hint and not ch.send_progress: pass else: - #await _print_interactive_line(f" ↳ {msg.content}") - await _print_interactive_line(f" [dim]↳ {msg.content}[/dim]") + await _print_interactive_line(msg.content) elif not turn_done.is_set(): if msg.content: From 774452795b758ba051faf0f7ff01819c3f704fa4 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 12 Mar 2026 16:09:24 +0000 Subject: [PATCH 15/21] fix(memory): use explicit function name in tool_choice for DashScope compatibility --- nanobot/agent/memory.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index 802dd04..1301d47 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -112,15 +112,17 @@ class MemoryStore: ## Conversation to Process {self._format_messages(messages)}""" + chat_messages = [ + {"role": "system", "content": "You are a memory consolidation agent. Call the save_memory tool with your consolidation of the conversation."}, + {"role": "user", "content": prompt}, + ] + try: response = await provider.chat_with_retry( - messages=[ - {"role": "system", "content": "You are a memory consolidation agent. Call the save_memory tool with your consolidation of the conversation."}, - {"role": "user", "content": prompt}, - ], + messages=chat_messages, tools=_SAVE_MEMORY_TOOL, model=model, - tool_choice="required", + tool_choice={"type": "function", "function": {"name": "save_memory"}}, ) if not response.has_tool_calls: From a09245e9192aac88076a6c2ed21054451ab1a4e8 Mon Sep 17 00:00:00 2001 From: Frank <97429702+tsubasakong@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:48:25 -0700 Subject: [PATCH 16/21] fix(qq): restore plain text replies for legacy clients --- nanobot/channels/qq.py | 8 ++++---- tests/test_qq_channel.py | 38 ++++++++++++++++++++++++++++++++++---- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 792cc12..80b7500 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -114,16 +114,16 @@ class QQChannel(BaseChannel): if msg_type == "group": await self._client.api.post_group_message( group_openid=msg.chat_id, - msg_type=2, - markdown={"content": msg.content}, + msg_type=0, + content=msg.content, msg_id=msg_id, msg_seq=self._msg_seq, ) else: await self._client.api.post_c2c_message( openid=msg.chat_id, - msg_type=2, - markdown={"content": msg.content}, + msg_type=0, + content=msg.content, msg_id=msg_id, msg_seq=self._msg_seq, ) diff --git a/tests/test_qq_channel.py b/tests/test_qq_channel.py index 90b4e60..db21468 100644 --- a/tests/test_qq_channel.py +++ b/tests/test_qq_channel.py @@ -44,7 +44,7 @@ async def test_on_group_message_routes_to_group_chat_id() -> None: @pytest.mark.asyncio -async def test_send_group_message_uses_group_api_with_msg_seq() -> None: +async def test_send_group_message_uses_plain_text_group_api_with_msg_seq() -> None: channel = QQChannel(QQConfig(app_id="app", secret="secret", allow_from=["*"]), MessageBus()) channel._client = _FakeClient() channel._chat_type_cache["group123"] = "group" @@ -60,7 +60,37 @@ async def test_send_group_message_uses_group_api_with_msg_seq() -> None: assert len(channel._client.api.group_calls) == 1 call = channel._client.api.group_calls[0] - assert call["group_openid"] == "group123" - assert call["msg_id"] == "msg1" - assert call["msg_seq"] == 2 + assert call == { + "group_openid": "group123", + "msg_type": 0, + "content": "hello", + "msg_id": "msg1", + "msg_seq": 2, + } assert not channel._client.api.c2c_calls + + +@pytest.mark.asyncio +async def test_send_c2c_message_uses_plain_text_c2c_api_with_msg_seq() -> None: + channel = QQChannel(QQConfig(app_id="app", secret="secret", allow_from=["*"]), MessageBus()) + channel._client = _FakeClient() + + await channel.send( + OutboundMessage( + channel="qq", + chat_id="user123", + content="hello", + metadata={"message_id": "msg1"}, + ) + ) + + assert len(channel._client.api.c2c_calls) == 1 + call = channel._client.api.c2c_calls[0] + assert call == { + "openid": "user123", + "msg_type": 0, + "content": "hello", + "msg_id": "msg1", + "msg_seq": 2, + } + assert not channel._client.api.group_calls From 127ac390632a612154090b4f881bda217900ba29 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Fri, 13 Mar 2026 10:23:15 +0800 Subject: [PATCH 17/21] fix: catch BaseException in MCP connection to handle CancelledError --- nanobot/agent/loop.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 5fe0ee0..dc76441 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -139,7 +139,7 @@ class AgentLoop: await self._mcp_stack.__aenter__() await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack) self._mcp_connected = True - except Exception as e: + except BaseException as e: logger.error("Failed to connect MCP servers (will retry next message): {}", e) if self._mcp_stack: try: From fb9d54da21d820291c37e2a12bbf3e07712697ed Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Fri, 13 Mar 2026 02:41:52 +0000 Subject: [PATCH 18/21] docs: update .gitignore to add .docs --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c50cab8..0d392d3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .worktrees/ .assets +.docs .env *.pyc dist/ @@ -7,7 +8,7 @@ build/ docs/ *.egg-info/ *.egg -*.pyc +*.pycs *.pyo *.pyd *.pyw From 6ad30f12f53082b46ee65ae7ef71b630b98fe9dc Mon Sep 17 00:00:00 2001 From: chengyongru Date: Fri, 13 Mar 2026 10:57:26 +0800 Subject: [PATCH 19/21] fix(restart): use -m nanobot for Windows compatibility On Windows, sys.argv[0] may be just "nanobot" without full path when running from PATH. os.execv() doesn't search PATH, causing restart to fail with "No such file or directory". Fix by using `python -m nanobot` instead of relying on sys.argv[0]. Fixes #1937 --- nanobot/agent/loop.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 5fe0ee0..05b8728 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -292,7 +292,9 @@ class AgentLoop: async def _do_restart(): await asyncio.sleep(1) - os.execv(sys.executable, [sys.executable] + sys.argv) + # Use -m nanobot instead of sys.argv[0] for Windows compatibility + # (sys.argv[0] may be just "nanobot" without full path on Windows) + os.execv(sys.executable, [sys.executable, "-m", "nanobot"] + sys.argv[1:]) asyncio.create_task(_do_restart()) From 4f77b9385cf9fa22e523cc35afdd9f8242c68203 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Fri, 13 Mar 2026 03:18:08 +0000 Subject: [PATCH 20/21] fix(memory): fallback to tool_choice=auto when provider rejects forced function call Some providers (e.g. Dashscope in thinking mode) reject object-style tool_choice with "does not support being set to required or object". Retry once with tool_choice="auto" instead of failing silently. Made-with: Cursor --- nanobot/agent/memory.py | 36 ++++++++++++++- tests/test_memory_consolidation_types.py | 57 ++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index 1301d47..e7eac88 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -57,6 +57,20 @@ def _normalize_save_memory_args(args: Any) -> dict[str, Any] | None: return args[0] if args and isinstance(args[0], dict) else None return args if isinstance(args, dict) else None +_TOOL_CHOICE_ERROR_MARKERS = ( + "tool_choice", + "toolchoice", + "does not support", + 'should be ["none", "auto"]', +) + + +def _is_tool_choice_unsupported(content: str | None) -> bool: + """Detect provider errors caused by forced tool_choice being unsupported.""" + text = (content or "").lower() + return any(m in text for m in _TOOL_CHOICE_ERROR_MARKERS) + + class MemoryStore: """Two-layer memory: MEMORY.md (long-term facts) + HISTORY.md (grep-searchable log).""" @@ -118,15 +132,33 @@ class MemoryStore: ] try: + forced = {"type": "function", "function": {"name": "save_memory"}} response = await provider.chat_with_retry( messages=chat_messages, tools=_SAVE_MEMORY_TOOL, model=model, - tool_choice={"type": "function", "function": {"name": "save_memory"}}, + tool_choice=forced, ) + if response.finish_reason == "error" and _is_tool_choice_unsupported( + response.content + ): + logger.warning("Forced tool_choice unsupported, retrying with auto") + response = await provider.chat_with_retry( + messages=chat_messages, + tools=_SAVE_MEMORY_TOOL, + model=model, + tool_choice="auto", + ) + if not response.has_tool_calls: - logger.warning("Memory consolidation: LLM did not call save_memory, skipping") + logger.warning( + "Memory consolidation: LLM did not call save_memory " + "(finish_reason={}, content_len={}, content_preview={})", + response.finish_reason, + len(response.content or ""), + (response.content or "")[:200], + ) return False args = _normalize_save_memory_args(response.tool_calls[0].arguments) diff --git a/tests/test_memory_consolidation_types.py b/tests/test_memory_consolidation_types.py index 69be858..f1280fc 100644 --- a/tests/test_memory_consolidation_types.py +++ b/tests/test_memory_consolidation_types.py @@ -288,3 +288,60 @@ class TestMemoryConsolidationTypeHandling: assert "temperature" not in kwargs assert "max_tokens" not in kwargs assert "reasoning_effort" not in kwargs + + @pytest.mark.asyncio + async def test_tool_choice_fallback_on_unsupported_error(self, tmp_path: Path) -> None: + """Forced tool_choice rejected by provider -> retry with auto and succeed.""" + store = MemoryStore(tmp_path) + error_resp = LLMResponse( + content="Error calling LLM: litellm.BadRequestError: " + "The tool_choice parameter does not support being set to required or object", + finish_reason="error", + tool_calls=[], + ) + ok_resp = _make_tool_response( + history_entry="[2026-01-01] Fallback worked.", + memory_update="# Memory\nFallback OK.", + ) + + call_log: list[dict] = [] + + async def _tracking_chat(**kwargs): + call_log.append(kwargs) + return error_resp if len(call_log) == 1 else ok_resp + + provider = AsyncMock() + provider.chat_with_retry = AsyncMock(side_effect=_tracking_chat) + messages = _make_messages(message_count=60) + + result = await store.consolidate(messages, provider, "test-model") + + assert result is True + assert len(call_log) == 2 + assert isinstance(call_log[0]["tool_choice"], dict) + assert call_log[1]["tool_choice"] == "auto" + assert "Fallback worked." in store.history_file.read_text() + + @pytest.mark.asyncio + async def test_tool_choice_fallback_auto_no_tool_call(self, tmp_path: Path) -> None: + """Forced rejected, auto retry also produces no tool call -> return False.""" + store = MemoryStore(tmp_path) + error_resp = LLMResponse( + content="Error: tool_choice must be none or auto", + finish_reason="error", + tool_calls=[], + ) + no_tool_resp = LLMResponse( + content="Here is a summary.", + finish_reason="stop", + tool_calls=[], + ) + + provider = AsyncMock() + provider.chat_with_retry = AsyncMock(side_effect=[error_resp, no_tool_resp]) + messages = _make_messages(message_count=60) + + result = await store.consolidate(messages, provider, "test-model") + + assert result is False + assert not store.history_file.exists() From 6d3a0ab6c93a7df0b04137b85ca560aba855bf83 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Fri, 13 Mar 2026 03:53:50 +0000 Subject: [PATCH 21/21] fix(memory): validate save_memory payload and raw-archive on repeated failure - Require both history_entry and memory_update, reject null/empty values - Fallback to tool_choice=auto when provider rejects forced function call - After 3 consecutive consolidation failures, raw-archive messages to HISTORY.md without LLM summarization to prevent context window overflow --- nanobot/agent/memory.py | 35 +++++++++++++++--- tests/test_memory_consolidation_types.py | 45 ++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index 8cc68bc..f220f23 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio import json import weakref +from datetime import datetime from pathlib import Path from typing import TYPE_CHECKING, Any, Callable @@ -74,10 +75,13 @@ def _is_tool_choice_unsupported(content: str | None) -> bool: class MemoryStore: """Two-layer memory: MEMORY.md (long-term facts) + HISTORY.md (grep-searchable log).""" + _MAX_FAILURES_BEFORE_RAW_ARCHIVE = 3 + def __init__(self, workspace: Path): self.memory_dir = ensure_dir(workspace / "memory") self.memory_file = self.memory_dir / "MEMORY.md" self.history_file = self.memory_dir / "HISTORY.md" + self._consecutive_failures = 0 def read_long_term(self) -> str: if self.memory_file.exists(): @@ -159,39 +163,60 @@ class MemoryStore: len(response.content or ""), (response.content or "")[:200], ) - return False + return self._fail_or_raw_archive(messages) args = _normalize_save_memory_args(response.tool_calls[0].arguments) if args is None: logger.warning("Memory consolidation: unexpected save_memory arguments") - return False + return self._fail_or_raw_archive(messages) if "history_entry" not in args or "memory_update" not in args: logger.warning("Memory consolidation: save_memory payload missing required fields") - return False + return self._fail_or_raw_archive(messages) entry = args["history_entry"] update = args["memory_update"] if entry is None or update is None: logger.warning("Memory consolidation: save_memory payload contains null required fields") - return False + return self._fail_or_raw_archive(messages) entry = _ensure_text(entry).strip() if not entry: logger.warning("Memory consolidation: history_entry is empty after normalization") - return False + return self._fail_or_raw_archive(messages) self.append_history(entry) update = _ensure_text(update) if update != current_memory: self.write_long_term(update) + self._consecutive_failures = 0 logger.info("Memory consolidation done for {} messages", len(messages)) return True except Exception: logger.exception("Memory consolidation failed") + return self._fail_or_raw_archive(messages) + + def _fail_or_raw_archive(self, messages: list[dict]) -> bool: + """Increment failure count; after threshold, raw-archive messages and return True.""" + self._consecutive_failures += 1 + if self._consecutive_failures < self._MAX_FAILURES_BEFORE_RAW_ARCHIVE: return False + self._raw_archive(messages) + self._consecutive_failures = 0 + return True + + def _raw_archive(self, messages: list[dict]) -> None: + """Fallback: dump raw messages to HISTORY.md without LLM summarization.""" + ts = datetime.now().strftime("%Y-%m-%d %H:%M") + self.append_history( + f"[{ts}] [RAW] {len(messages)} messages\n" + f"{self._format_messages(messages)}" + ) + logger.warning( + "Memory consolidation degraded: raw-archived {} messages", len(messages) + ) class MemoryConsolidator: diff --git a/tests/test_memory_consolidation_types.py b/tests/test_memory_consolidation_types.py index a7c872e..d63cc90 100644 --- a/tests/test_memory_consolidation_types.py +++ b/tests/test_memory_consolidation_types.py @@ -431,3 +431,48 @@ class TestMemoryConsolidationTypeHandling: assert result is False assert not store.history_file.exists() + + @pytest.mark.asyncio + async def test_raw_archive_after_consecutive_failures(self, tmp_path: Path) -> None: + """After 3 consecutive failures, raw-archive messages and return True.""" + store = MemoryStore(tmp_path) + no_tool = LLMResponse(content="No tool call.", finish_reason="stop", tool_calls=[]) + provider = AsyncMock() + provider.chat_with_retry = AsyncMock(return_value=no_tool) + messages = _make_messages(message_count=10) + + assert await store.consolidate(messages, provider, "m") is False + assert await store.consolidate(messages, provider, "m") is False + assert await store.consolidate(messages, provider, "m") is True + + assert store.history_file.exists() + content = store.history_file.read_text() + assert "[RAW]" in content + assert "10 messages" in content + assert "msg0" in content + assert not store.memory_file.exists() + + @pytest.mark.asyncio + async def test_raw_archive_counter_resets_on_success(self, tmp_path: Path) -> None: + """A successful consolidation resets the failure counter.""" + store = MemoryStore(tmp_path) + no_tool = LLMResponse(content="Nope.", finish_reason="stop", tool_calls=[]) + ok_resp = _make_tool_response( + history_entry="[2026-01-01] OK.", + memory_update="# Memory\nOK.", + ) + messages = _make_messages(message_count=10) + + provider = AsyncMock() + provider.chat_with_retry = AsyncMock(return_value=no_tool) + assert await store.consolidate(messages, provider, "m") is False + assert await store.consolidate(messages, provider, "m") is False + assert store._consecutive_failures == 2 + + provider.chat_with_retry = AsyncMock(return_value=ok_resp) + assert await store.consolidate(messages, provider, "m") is True + assert store._consecutive_failures == 0 + + provider.chat_with_retry = AsyncMock(return_value=no_tool) + assert await store.consolidate(messages, provider, "m") is False + assert store._consecutive_failures == 1