From 7f1dca3186b8497ba1dbf2dc2a629fc71bd3541d Mon Sep 17 00:00:00 2001 From: Shiniese <135589327+Shiniese@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:16:58 +0800 Subject: [PATCH 01/22] feat: unify web tool config under WebToolsConfig + add web tool toggle controls - Rename WebSearchConfig references to the new WebToolsConfig root struct that wraps both search config and global proxy settings - Add 'enable' flag to WebToolsConfig to allow fully disabling all web-related tools (WebSearch, WebFetch) at runtime - Update AgentLoop and SubagentManager to receive the full web config object instead of separate web_search_config/web_proxy parameters - Update CLI command initialization to pass the consolidated web config struct instead of split fields - Change default web search provider from brave to duckduckgo for better out-of-the-box usability (no API key required) --- nanobot/agent/loop.py | 18 ++++++++---------- nanobot/agent/subagent.py | 26 +++++++++++++------------- nanobot/cli/commands.py | 6 ++---- nanobot/config/schema.py | 3 ++- 4 files changed, 25 insertions(+), 28 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 63ee92ca5..e4f4ec991 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -33,7 +33,7 @@ from nanobot.providers.base import LLMProvider from nanobot.session.manager import Session, SessionManager if TYPE_CHECKING: - from nanobot.config.schema import ChannelsConfig, ExecToolConfig, WebSearchConfig + from nanobot.config.schema import ChannelsConfig, ExecToolConfig, WebToolsConfig from nanobot.cron.service import CronService @@ -59,8 +59,7 @@ class AgentLoop: model: str | None = None, max_iterations: int = 40, context_window_tokens: int = 65_536, - web_search_config: WebSearchConfig | None = None, - web_proxy: str | None = None, + web_config: WebToolsConfig | None = None, exec_config: ExecToolConfig | None = None, cron_service: CronService | None = None, restrict_to_workspace: bool = False, @@ -69,7 +68,7 @@ class AgentLoop: channels_config: ChannelsConfig | None = None, timezone: str | None = None, ): - from nanobot.config.schema import ExecToolConfig, WebSearchConfig + from nanobot.config.schema import ExecToolConfig, WebToolsConfig self.bus = bus self.channels_config = channels_config @@ -78,8 +77,7 @@ class AgentLoop: self.model = model or provider.get_default_model() self.max_iterations = max_iterations self.context_window_tokens = context_window_tokens - self.web_search_config = web_search_config or WebSearchConfig() - self.web_proxy = web_proxy + self.web_config = web_config or WebToolsConfig() self.exec_config = exec_config or ExecToolConfig() self.cron_service = cron_service self.restrict_to_workspace = restrict_to_workspace @@ -95,8 +93,7 @@ class AgentLoop: workspace=workspace, bus=bus, model=self.model, - web_search_config=self.web_search_config, - web_proxy=web_proxy, + web_config=self.web_config, exec_config=self.exec_config, restrict_to_workspace=restrict_to_workspace, ) @@ -142,8 +139,9 @@ class AgentLoop: restrict_to_workspace=self.restrict_to_workspace, path_append=self.exec_config.path_append, )) - self.tools.register(WebSearchTool(config=self.web_search_config, proxy=self.web_proxy)) - self.tools.register(WebFetchTool(proxy=self.web_proxy)) + if self.web_config.enable: + self.tools.register(WebSearchTool(config=self.web_config.search, proxy=self.web_config.proxy)) + self.tools.register(WebFetchTool(proxy=self.web_config.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 5266fc8b1..6487bc11c 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -17,7 +17,7 @@ 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.config.schema import ExecToolConfig, WebToolsConfig from nanobot.providers.base import LLMProvider @@ -30,8 +30,7 @@ class SubagentManager: workspace: Path, bus: MessageBus, model: str | None = None, - web_search_config: "WebSearchConfig | None" = None, - web_proxy: str | None = None, + web_config: "WebToolsConfig | None" = None, exec_config: "ExecToolConfig | None" = None, restrict_to_workspace: bool = False, ): @@ -41,8 +40,7 @@ class SubagentManager: self.workspace = workspace self.bus = bus self.model = model or provider.get_default_model() - self.web_search_config = web_search_config or WebSearchConfig() - self.web_proxy = web_proxy + self.web_config = web_config or WebToolsConfig() self.exec_config = exec_config or ExecToolConfig() self.restrict_to_workspace = restrict_to_workspace self.runner = AgentRunner(provider) @@ -100,14 +98,16 @@ class SubagentManager: tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) tools.register(EditFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir)) - tools.register(ExecTool( - working_dir=str(self.workspace), - timeout=self.exec_config.timeout, - restrict_to_workspace=self.restrict_to_workspace, - path_append=self.exec_config.path_append, - )) - tools.register(WebSearchTool(config=self.web_search_config, proxy=self.web_proxy)) - tools.register(WebFetchTool(proxy=self.web_proxy)) + if self.exec_config.enable: + tools.register(ExecTool( + working_dir=str(self.workspace), + timeout=self.exec_config.timeout, + restrict_to_workspace=self.restrict_to_workspace, + path_append=self.exec_config.path_append, + )) + if self.web_config.enable: + tools.register(WebSearchTool(config=self.web_config.search, proxy=self.web_config.proxy)) + tools.register(WebFetchTool(proxy=self.web_config.proxy)) system_prompt = self._build_subagent_prompt() messages: list[dict[str, Any]] = [ diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index cacb61ae6..c3727d319 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -541,8 +541,7 @@ def gateway( model=config.agents.defaults.model, max_iterations=config.agents.defaults.max_tool_iterations, context_window_tokens=config.agents.defaults.context_window_tokens, - web_search_config=config.tools.web.search, - web_proxy=config.tools.web.proxy or None, + web_config=config.tools.web, exec_config=config.tools.exec, cron_service=cron, restrict_to_workspace=config.tools.restrict_to_workspace, @@ -747,8 +746,7 @@ def agent( model=config.agents.defaults.model, max_iterations=config.agents.defaults.max_tool_iterations, context_window_tokens=config.agents.defaults.context_window_tokens, - web_search_config=config.tools.web.search, - web_proxy=config.tools.web.proxy or None, + web_config=config.tools.web, exec_config=config.tools.exec, cron_service=cron, restrict_to_workspace=config.tools.restrict_to_workspace, diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index c8b69b42e..1978a17c8 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -107,7 +107,7 @@ class GatewayConfig(Base): class WebSearchConfig(Base): """Web search tool configuration.""" - provider: str = "brave" # brave, tavily, duckduckgo, searxng, jina + provider: str = "duckduckgo" # brave, tavily, duckduckgo, searxng, jina api_key: str = "" base_url: str = "" # SearXNG base URL max_results: int = 5 @@ -116,6 +116,7 @@ class WebSearchConfig(Base): class WebToolsConfig(Base): """Web tools configuration.""" + enable: bool = True proxy: str | None = ( None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" ) From 0340f81cfd47a2a60e588b6fc87f2f3ad0887237 Mon Sep 17 00:00:00 2001 From: qcypggs Date: Mon, 30 Mar 2026 19:25:55 +0800 Subject: [PATCH 02/22] fix: restore Weixin typing indicator Fetch and cache typing tickets so the Weixin channel shows typing while nanobot is processing and clears it after the final reply. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- nanobot/channels/weixin.py | 100 +++++++++++++++++++++++++- tests/channels/test_weixin_channel.py | 74 +++++++++++++++++++ 2 files changed, 173 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py index f09ef95f7..9e2caae3f 100644 --- a/nanobot/channels/weixin.py +++ b/nanobot/channels/weixin.py @@ -13,7 +13,6 @@ import asyncio import base64 import hashlib import json -import mimetypes import os import re import time @@ -124,6 +123,8 @@ class WeixinChannel(BaseChannel): self._poll_task: asyncio.Task | None = None self._next_poll_timeout_s: int = DEFAULT_LONG_POLL_TIMEOUT_S self._session_pause_until: float = 0.0 + self._typing_tasks: dict[str, asyncio.Task] = {} + self._typing_tickets: dict[str, str] = {} # ------------------------------------------------------------------ # State persistence @@ -158,6 +159,15 @@ class WeixinChannel(BaseChannel): } else: self._context_tokens = {} + typing_tickets = data.get("typing_tickets", {}) + if isinstance(typing_tickets, dict): + self._typing_tickets = { + str(user_id): str(ticket) + for user_id, ticket in typing_tickets.items() + if str(user_id).strip() and str(ticket).strip() + } + else: + self._typing_tickets = {} base_url = data.get("base_url", "") if base_url: self.config.base_url = base_url @@ -173,6 +183,7 @@ class WeixinChannel(BaseChannel): "token": self._token, "get_updates_buf": self._get_updates_buf, "context_tokens": self._context_tokens, + "typing_tickets": self._typing_tickets, "base_url": self.config.base_url, } state_file.write_text(json.dumps(data, ensure_ascii=False)) @@ -415,6 +426,8 @@ class WeixinChannel(BaseChannel): self._running = False if self._poll_task and not self._poll_task.done(): self._poll_task.cancel() + for chat_id in list(self._typing_tasks): + await self._stop_typing(chat_id, clear_remote=False) if self._client: await self._client.aclose() self._client = None @@ -631,6 +644,8 @@ class WeixinChannel(BaseChannel): len(content), ) + await self._start_typing(from_user_id, ctx_token) + await self._handle_message( sender_id=from_user_id, chat_id=from_user_id, @@ -720,6 +735,10 @@ class WeixinChannel(BaseChannel): logger.warning("WeChat send blocked: {}", e) return + is_progress = bool((msg.metadata or {}).get("_progress", False)) + if not is_progress: + await self._stop_typing(msg.chat_id, clear_remote=True) + content = msg.content.strip() ctx_token = self._context_tokens.get(msg.chat_id, "") if not ctx_token: @@ -753,6 +772,85 @@ class WeixinChannel(BaseChannel): logger.error("Error sending WeChat message: {}", e) raise + async def _get_typing_ticket(self, user_id: str, context_token: str) -> str: + """Fetch and cache typing ticket for a user/context pair.""" + if not self._client or not self._token or not user_id or not context_token: + return "" + cached = self._typing_tickets.get(user_id, "") + if cached: + return cached + try: + data = await self._api_post( + "ilink/bot/getconfig", + { + "ilink_user_id": user_id, + "context_token": context_token, + }, + ) + except Exception as e: + logger.debug("WeChat getconfig failed for {}: {}", user_id, e) + return "" + ticket = str(data.get("typing_ticket") or "").strip() + if ticket: + self._typing_tickets[user_id] = ticket + self._save_state() + return ticket + + async def _send_typing_status(self, to_user_id: str, typing_ticket: str, status: int) -> None: + if not typing_ticket: + return + await self._api_post( + "ilink/bot/sendtyping", + { + "ilink_user_id": to_user_id, + "typing_ticket": typing_ticket, + "status": status, + }, + ) + + async def _start_typing(self, chat_id: str, context_token: str) -> None: + if not self._client or not self._token or not chat_id or not context_token: + return + await self._stop_typing(chat_id, clear_remote=False) + ticket = await self._get_typing_ticket(chat_id, context_token) + if not ticket: + return + try: + await self._send_typing_status(chat_id, ticket, 1) + except Exception as e: + logger.debug("WeChat typing indicator failed for {}: {}", chat_id, e) + return + + async def typing_loop() -> None: + try: + while self._running: + await asyncio.sleep(5) + await self._send_typing_status(chat_id, ticket, 1) + except asyncio.CancelledError: + pass + except Exception as e: + logger.debug("WeChat typing keepalive stopped for {}: {}", chat_id, e) + + self._typing_tasks[chat_id] = asyncio.create_task(typing_loop()) + + async def _stop_typing(self, chat_id: str, *, clear_remote: bool) -> None: + task = self._typing_tasks.pop(chat_id, None) + if task and not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + if not clear_remote: + return + ticket = self._typing_tickets.get(chat_id, "") + if not ticket: + return + try: + await self._send_typing_status(chat_id, ticket, 2) + except Exception as e: + logger.debug("WeChat typing clear failed for {}: {}", chat_id, e) + async def _send_text( self, to_user_id: str, diff --git a/tests/channels/test_weixin_channel.py b/tests/channels/test_weixin_channel.py index 54d9bd93f..35b01db8b 100644 --- a/tests/channels/test_weixin_channel.py +++ b/tests/channels/test_weixin_channel.py @@ -278,3 +278,77 @@ async def test_process_message_skips_bot_messages() -> None: ) assert bus.inbound_size == 0 + + +@pytest.mark.asyncio +async def test_process_message_fetches_typing_ticket_and_starts_typing() -> None: + channel, _bus = _make_channel() + channel._running = True + channel._client = object() + channel._token = "token" + channel._api_post = AsyncMock(return_value={"typing_ticket": "ticket-1"}) + + await channel._process_message( + { + "message_type": 1, + "message_id": "m-typing", + "from_user_id": "wx-user", + "context_token": "ctx-typing", + "item_list": [ + {"type": ITEM_TEXT, "text_item": {"text": "hello"}}, + ], + } + ) + + assert channel._typing_tickets["wx-user"] == "ticket-1" + assert "wx-user" in channel._typing_tasks + await channel._stop_typing("wx-user", clear_remote=False) + + +@pytest.mark.asyncio +async def test_send_final_message_clears_typing_indicator() -> None: + channel, _bus = _make_channel() + channel._client = object() + channel._token = "token" + channel._context_tokens["wx-user"] = "ctx-2" + channel._typing_tickets["wx-user"] = "ticket-2" + channel._send_text = AsyncMock() + channel._api_post = AsyncMock(return_value={}) + + await channel.send( + type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})() + ) + + channel._send_text.assert_awaited_once_with("wx-user", "pong", "ctx-2") + channel._api_post.assert_awaited_once() + endpoint, body = channel._api_post.await_args.args + assert endpoint == "ilink/bot/sendtyping" + assert body["status"] == 2 + assert body["typing_ticket"] == "ticket-2" + + +@pytest.mark.asyncio +async def test_send_progress_message_keeps_typing_indicator() -> None: + channel, _bus = _make_channel() + channel._client = object() + channel._token = "token" + channel._context_tokens["wx-user"] = "ctx-2" + channel._typing_tickets["wx-user"] = "ticket-2" + channel._send_text = AsyncMock() + channel._api_post = AsyncMock(return_value={}) + + await channel.send( + type( + "Msg", + (), + { + "chat_id": "wx-user", + "content": "thinking", + "media": [], + "metadata": {"_progress": True}, + }, + )() + ) + + channel._send_text.assert_awaited_once_with("wx-user", "thinking", "ctx-2") + channel._api_post.assert_not_awaited() From cf6c9793392e3816f093f8673abcb44c40db8ee7 Mon Sep 17 00:00:00 2001 From: Lingao Meng Date: Fri, 3 Apr 2026 14:40:31 +0800 Subject: [PATCH 03/22] feat(provider): add Xiaomi MiMo LLM support Register xiaomi_mimo as an OpenAI-compatible provider with its API base URL, add xiaomi_mimo to the provider config schema, and document it in README. Signed-off-by: Lingao Meng --- README.md | 1 + nanobot/config/schema.py | 1 + nanobot/providers/base.py | 2 +- nanobot/providers/registry.py | 9 +++++++++ 4 files changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8a8c864d0..e6f266bef 100644 --- a/README.md +++ b/README.md @@ -875,6 +875,7 @@ Config file: `~/.nanobot/config.json` | `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) | +| `mimo` | LLM (MiMo) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) | | `ollama` | LLM (local, Ollama) | — | | `mistral` | LLM | [docs.mistral.ai](https://docs.mistral.ai/) | | `stepfun` | LLM (Step Fun/阶跃星辰) | [platform.stepfun.com](https://platform.stepfun.com) | diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 602b8a911..e46663554 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -81,6 +81,7 @@ class ProvidersConfig(Base): minimax: ProviderConfig = Field(default_factory=ProviderConfig) mistral: ProviderConfig = Field(default_factory=ProviderConfig) stepfun: ProviderConfig = Field(default_factory=ProviderConfig) # Step Fun (阶跃星辰) + xiaomi_mimo: ProviderConfig = Field(default_factory=ProviderConfig) # Xiaomi MIMO (小米) aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动) volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎) diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index 852e9c973..b666d0f37 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -49,7 +49,7 @@ class LLMResponse: tool_calls: list[ToolCallRequest] = field(default_factory=list) finish_reason: str = "stop" usage: dict[str, int] = field(default_factory=dict) - reasoning_content: str | None = None # Kimi, DeepSeek-R1 etc. + reasoning_content: str | None = None # Kimi, DeepSeek-R1, MiMo etc. thinking_blocks: list[dict] | None = None # Anthropic extended thinking @property diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 8435005e1..75b82c1ec 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -297,6 +297,15 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( backend="openai_compat", default_api_base="https://api.stepfun.com/v1", ), + # Xiaomi MIMO (小米): OpenAI-compatible API + ProviderSpec( + name="xiaomi_mimo", + keywords=("xiaomi_mimo", "mimo"), + env_key="XIAOMIMIMO_API_KEY", + display_name="Xiaomi MIMO", + backend="openai_compat", + default_api_base="https://api.xiaomimimo.com/v1", + ), # === Local deployment (matched by config key, NOT by api_base) ========= # vLLM / any OpenAI-compatible local server ProviderSpec( From 3c3a72ef82b6d93073cf4f260f803dbbbc443b4f Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Fri, 3 Apr 2026 16:02:23 +0000 Subject: [PATCH 04/22] update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index fce6e07f8..08217c5b1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .assets .docs .env +.web *.pyc dist/ build/ From cb84f2b908e5219502dca0ae639fb92196c7f307 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Fri, 3 Apr 2026 16:18:36 +0000 Subject: [PATCH 05/22] docs: update nanobot news section --- README.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 8a8c864d0..60714b34b 100644 --- a/README.md +++ b/README.md @@ -20,13 +20,20 @@ ## 📢 News -> [!IMPORTANT] -> **Security note:** Due to `litellm` supply chain poisoning, **please check your Python environment ASAP** and refer to this [advisory](https://github.com/HKUDS/nanobot/discussions/2445) for details. We have fully removed the `litellm` since **v0.1.4.post6**. - +- **2026-04-02** 🧱 **Long-running tasks** run more reliably — core runtime hardening. +- **2026-04-01** 🔑 GitHub Copilot auth restored; stricter workspace paths; OpenRouter Claude caching fix. +- **2026-03-31** 🛰️ WeChat multimodal alignment, Discord/Matrix polish, Python SDK facade, MCP and tool fixes. +- **2026-03-30** 🧩 OpenAI-compatible API tightened; composable agent lifecycle hooks. +- **2026-03-29** 💬 WeChat voice, typing, QR/media resilience; fixed-session OpenAI-compatible API. +- **2026-03-28** 📚 Provider docs refresh; skill template wording fix. - **2026-03-27** 🚀 Released **v0.1.4.post6** — architecture decoupling, litellm removal, end-to-end streaming, WeChat channel, and a security fix. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post6) for details. - **2026-03-26** 🏗️ Agent runner extracted and lifecycle hooks unified; stream delta coalescing at boundaries. - **2026-03-25** 🌏 StepFun provider, configurable timezone, Gemini thought signatures. - **2026-03-24** 🔧 WeChat compatibility, Feishu CardKit streaming, test suite restructured. + +
+Earlier news + - **2026-03-23** 🔧 Command routing refactored for plugins, WhatsApp/WeChat media, unified channel login CLI. - **2026-03-22** ⚡ End-to-end streaming, WeChat channel, Anthropic cache optimization, `/status` command. - **2026-03-21** 🔒 Replace `litellm` with native `openai` + `anthropic` SDKs. Please see [commit](https://github.com/HKUDS/nanobot/commit/3dfdab7). @@ -34,10 +41,6 @@ - **2026-03-19** 💬 Telegram gets more resilient under load; Feishu now renders code blocks properly. - **2026-03-18** 📷 Telegram can now send media via URL. Cron schedules show human-readable details. - **2026-03-17** ✨ Feishu formatting glow-up, Slack reacts when done, custom endpoints support extra headers, and image handling is more reliable. - -
-Earlier news - - **2026-03-16** 🚀 Released **v0.1.4.post5** — a refinement-focused release with stronger reliability and channel support, and a more dependable day-to-day experience. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post5) for details. - **2026-03-15** 🧩 DingTalk rich media, smarter built-in skills, and cleaner model compatibility. - **2026-03-14** 💬 Channel plugins, Feishu replies, and steadier MCP, QQ, and media handling. From 0fa82298d315150254bc6ccaac364f2504941a46 Mon Sep 17 00:00:00 2001 From: Flo Date: Wed, 1 Apr 2026 09:00:52 +0300 Subject: [PATCH 06/22] fix(telegram): support commands with bot username suffix in groups (#2553) * fix(telegram): support commands with bot username suffix in groups * fix(command): preserve metadata in builtin command responses --- nanobot/channels/telegram.py | 21 +++++++++++++-------- nanobot/command/builtin.py | 15 +++++++++++---- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 916b9ba64..439d1c4d9 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -275,13 +275,10 @@ class TelegramChannel(BaseChannel): 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("stop", self._forward_command)) - self._app.add_handler(CommandHandler("restart", self._forward_command)) - self._app.add_handler(CommandHandler("status", self._forward_command)) - self._app.add_handler(CommandHandler("help", self._on_help)) + # Add command handlers (using Regex to support @username suffixes before bot initialization) + self._app.add_handler(MessageHandler(filters.Regex(r"^/start(?:@\w+)?$"), self._on_start)) + self._app.add_handler(MessageHandler(filters.Regex(r"^/(new|stop|restart|status)(?:@\w+)?$"), self._forward_command)) + self._app.add_handler(MessageHandler(filters.Regex(r"^/help(?:@\w+)?$"), self._on_help)) # Add message handler for text, photos, voice, documents self._app.add_handler( @@ -765,10 +762,18 @@ class TelegramChannel(BaseChannel): message = update.message user = update.effective_user self._remember_thread_context(message) + + # Strip @bot_username suffix if present + content = message.text or "" + if content.startswith("/") and "@" in content: + cmd_part, *rest = content.split(" ", 1) + cmd_part = cmd_part.split("@")[0] + content = f"{cmd_part} {rest[0]}" if rest else cmd_part + await self._handle_message( sender_id=self._sender_id(user), chat_id=str(message.chat_id), - content=message.text or "", + content=content, metadata=self._build_message_metadata(message, user), session_key=self._derive_topic_session_key(message), ) diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index 643397057..05d4fc163 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -26,7 +26,10 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage: sub_cancelled = await loop.subagents.cancel_by_session(msg.session_key) total = cancelled + sub_cancelled content = f"Stopped {total} task(s)." if total else "No active task to stop." - return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=content) + return OutboundMessage( + channel=msg.channel, chat_id=msg.chat_id, content=content, + metadata=dict(msg.metadata or {}) + ) async def cmd_restart(ctx: CommandContext) -> OutboundMessage: @@ -38,7 +41,10 @@ async def cmd_restart(ctx: CommandContext) -> OutboundMessage: os.execv(sys.executable, [sys.executable, "-m", "nanobot"] + sys.argv[1:]) asyncio.create_task(_do_restart()) - return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content="Restarting...") + return OutboundMessage( + channel=msg.channel, chat_id=msg.chat_id, content="Restarting...", + metadata=dict(msg.metadata or {}) + ) async def cmd_status(ctx: CommandContext) -> OutboundMessage: @@ -62,7 +68,7 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage: session_msg_count=len(session.get_history(max_messages=0)), context_tokens_estimate=ctx_est, ), - metadata={"render_as": "text"}, + metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"}, ) @@ -79,6 +85,7 @@ async def cmd_new(ctx: CommandContext) -> OutboundMessage: return OutboundMessage( channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content="New session started.", + metadata=dict(ctx.msg.metadata or {}) ) @@ -88,7 +95,7 @@ async def cmd_help(ctx: CommandContext) -> OutboundMessage: channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content=build_help_text(), - metadata={"render_as": "text"}, + metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"}, ) From 0709fda568887d577412166a6a707de07d53855b Mon Sep 17 00:00:00 2001 From: Flo Date: Wed, 1 Apr 2026 09:13:08 +0300 Subject: [PATCH 07/22] fix(telegram): handle RetryAfter delay internally in channel (#2552) --- nanobot/channels/telegram.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 439d1c4d9..8cb85844c 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -432,7 +432,9 @@ class TelegramChannel(BaseChannel): await self._send_text(chat_id, chunk, reply_params, thread_kwargs) async def _call_with_retry(self, fn, *args, **kwargs): - """Call an async Telegram API function with retry on pool/network timeout.""" + """Call an async Telegram API function with retry on pool/network timeout and RetryAfter.""" + from telegram.error import RetryAfter + for attempt in range(1, _SEND_MAX_RETRIES + 1): try: return await fn(*args, **kwargs) @@ -445,6 +447,15 @@ class TelegramChannel(BaseChannel): attempt, _SEND_MAX_RETRIES, delay, ) await asyncio.sleep(delay) + except RetryAfter as e: + if attempt == _SEND_MAX_RETRIES: + raise + delay = float(e.retry_after) + logger.warning( + "Telegram Flood Control (attempt {}/{}), retrying in {:.1f}s", + attempt, _SEND_MAX_RETRIES, delay, + ) + await asyncio.sleep(delay) async def _send_text( self, From 2e5308ff28e9857bc99efcd37390970421676d8d Mon Sep 17 00:00:00 2001 From: Flo Date: Wed, 1 Apr 2026 09:14:42 +0300 Subject: [PATCH 08/22] fix(telegram): remove acknowledgment reaction when response completes (#2564) --- nanobot/channels/telegram.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 8cb85844c..cacecd735 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -359,9 +359,14 @@ class TelegramChannel(BaseChannel): logger.warning("Telegram bot not running") return - # Only stop typing indicator for final responses + # Only stop typing indicator and remove reaction for final responses if not msg.metadata.get("_progress", False): self._stop_typing(msg.chat_id) + if reply_to_message_id := msg.metadata.get("message_id"): + try: + await self._remove_reaction(msg.chat_id, int(reply_to_message_id)) + except ValueError: + pass try: chat_id = int(msg.chat_id) @@ -506,6 +511,11 @@ class TelegramChannel(BaseChannel): if stream_id is not None and buf.stream_id is not None and buf.stream_id != stream_id: return self._stop_typing(chat_id) + if reply_to_message_id := meta.get("message_id"): + try: + await self._remove_reaction(chat_id, int(reply_to_message_id)) + except ValueError: + pass try: html = _markdown_to_telegram_html(buf.text) await self._call_with_retry( @@ -919,6 +929,19 @@ class TelegramChannel(BaseChannel): except Exception as e: logger.debug("Telegram reaction failed: {}", e) + async def _remove_reaction(self, chat_id: str, message_id: int) -> None: + """Remove emoji reaction from a message (best-effort, non-blocking).""" + if not self._app: + return + try: + await self._app.bot.set_message_reaction( + chat_id=int(chat_id), + message_id=message_id, + reaction=[], + ) + except Exception as e: + logger.debug("Telegram reaction removal failed: {}", e) + async def _typing_loop(self, chat_id: str) -> None: """Repeatedly send 'typing' action until cancelled.""" try: From 49c40e6b31daf932f0486f0cfaed55bd440e21bd Mon Sep 17 00:00:00 2001 From: Flo Date: Wed, 1 Apr 2026 09:16:51 +0300 Subject: [PATCH 09/22] feat(telegram): include author context in reply tags (#2605) (#2606) * feat(telegram): include author context in reply tags (#2605) * fix(telegram): handle missing attributes in reply_user safely --- nanobot/channels/telegram.py | 21 ++++++++++--- tests/channels/test_telegram_channel.py | 39 ++++++++++++++++--------- 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index cacecd735..72d60a19b 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -637,8 +637,7 @@ class TelegramChannel(BaseChannel): "reply_to_message_id": getattr(reply_to, "message_id", None) if reply_to else None, } - @staticmethod - def _extract_reply_context(message) -> str | None: + async def _extract_reply_context(self, message) -> str | None: """Extract text from the message being replied to, if any.""" reply = getattr(message, "reply_to_message", None) if not reply: @@ -646,7 +645,21 @@ class TelegramChannel(BaseChannel): 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 + + if not text: + return None + + bot_id, _ = await self._ensure_bot_identity() + reply_user = getattr(reply, "from_user", None) + + if bot_id and reply_user and getattr(reply_user, "id", None) == bot_id: + return f"[Reply to bot: {text}]" + elif reply_user and getattr(reply_user, "username", None): + return f"[Reply to @{reply_user.username}: {text}]" + elif reply_user and getattr(reply_user, "first_name", None): + return f"[Reply to {reply_user.first_name}: {text}]" + else: + return f"[Reply to: {text}]" async def _download_message_media( self, msg, *, add_failure_content: bool = False @@ -838,7 +851,7 @@ class TelegramChannel(BaseChannel): # Reply context: text and/or media from the replied-to message reply = getattr(message, "reply_to_message", None) if reply is not None: - reply_ctx = self._extract_reply_context(message) + reply_ctx = await 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 diff --git a/tests/channels/test_telegram_channel.py b/tests/channels/test_telegram_channel.py index 972f8ab6e..c793b1224 100644 --- a/tests/channels/test_telegram_channel.py +++ b/tests/channels/test_telegram_channel.py @@ -647,43 +647,56 @@ async def test_group_policy_open_accepts_plain_group_message() -> None: assert channel._app.bot.get_me_calls == 0 -def test_extract_reply_context_no_reply() -> None: +@pytest.mark.asyncio +async def test_extract_reply_context_no_reply() -> None: """When there is no reply_to_message, _extract_reply_context returns None.""" + channel = TelegramChannel(TelegramConfig(enabled=True, token="123:abc"), MessageBus()) message = SimpleNamespace(reply_to_message=None) - assert TelegramChannel._extract_reply_context(message) is None + assert await channel._extract_reply_context(message) is None -def test_extract_reply_context_with_text() -> None: +@pytest.mark.asyncio +async def test_extract_reply_context_with_text() -> None: """When reply has text, return prefixed string.""" - reply = SimpleNamespace(text="Hello world", caption=None) + channel = TelegramChannel(TelegramConfig(enabled=True, token="123:abc"), MessageBus()) + channel._app = _FakeApp(lambda: None) + reply = SimpleNamespace(text="Hello world", caption=None, from_user=SimpleNamespace(id=2, username="testuser", first_name="Test")) message = SimpleNamespace(reply_to_message=reply) - assert TelegramChannel._extract_reply_context(message) == "[Reply to: Hello world]" + assert await channel._extract_reply_context(message) == "[Reply to @testuser: Hello world]" -def test_extract_reply_context_with_caption_only() -> None: +@pytest.mark.asyncio +async 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") + channel = TelegramChannel(TelegramConfig(enabled=True, token="123:abc"), MessageBus()) + channel._app = _FakeApp(lambda: None) + reply = SimpleNamespace(text=None, caption="Photo caption", from_user=SimpleNamespace(id=2, username=None, first_name="Test")) message = SimpleNamespace(reply_to_message=reply) - assert TelegramChannel._extract_reply_context(message) == "[Reply to: Photo caption]" + assert await channel._extract_reply_context(message) == "[Reply to Test: Photo caption]" -def test_extract_reply_context_truncation() -> None: +@pytest.mark.asyncio +async def test_extract_reply_context_truncation() -> None: """Reply text is truncated at TELEGRAM_REPLY_CONTEXT_MAX_LEN.""" + channel = TelegramChannel(TelegramConfig(enabled=True, token="123:abc"), MessageBus()) + channel._app = _FakeApp(lambda: None) long_text = "x" * (TELEGRAM_REPLY_CONTEXT_MAX_LEN + 100) - reply = SimpleNamespace(text=long_text, caption=None) + reply = SimpleNamespace(text=long_text, caption=None, from_user=SimpleNamespace(id=2, username=None, first_name=None)) message = SimpleNamespace(reply_to_message=reply) - result = TelegramChannel._extract_reply_context(message) + result = await channel._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_returns_none() -> None: +@pytest.mark.asyncio +async def test_extract_reply_context_no_text_returns_none() -> None: """When reply has no text/caption, _extract_reply_context returns None (media handled separately).""" + channel = TelegramChannel(TelegramConfig(enabled=True, token="123:abc"), MessageBus()) reply = SimpleNamespace(text=None, caption=None) message = SimpleNamespace(reply_to_message=reply) - assert TelegramChannel._extract_reply_context(message) is None + assert await channel._extract_reply_context(message) is None @pytest.mark.asyncio From 06989fd65b606756148817f77bcaa15e257faef2 Mon Sep 17 00:00:00 2001 From: daliu858 Date: Wed, 1 Apr 2026 14:10:54 +0800 Subject: [PATCH 10/22] feat(qq): add configurable instant acknowledgment message (#2561) Add ack_message config field to QQConfig (default: Processing...). When non-empty, sends an instant text reply before agent processing begins, filling the silence gap for users. Uses existing _send_text_only method; failure is logged but never blocks normal message handling. Made-with: Cursor --- nanobot/channels/qq.py | 12 ++ tests/channels/test_qq_ack_message.py | 172 ++++++++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 tests/channels/test_qq_ack_message.py diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index b9d2d64d8..bef2cf27a 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -134,6 +134,7 @@ class QQConfig(Base): secret: str = "" allow_from: list[str] = Field(default_factory=list) msg_format: Literal["plain", "markdown"] = "plain" + ack_message: str = "⏳ Processing..." # Optional: directory to save inbound attachments. If empty, use nanobot get_media_dir("qq"). media_dir: str = "" @@ -484,6 +485,17 @@ class QQChannel(BaseChannel): if not content and not media_paths: return + if self.config.ack_message: + try: + await self._send_text_only( + chat_id=chat_id, + is_group=is_group, + msg_id=data.id, + content=self.config.ack_message, + ) + except Exception: + logger.debug("QQ ack message failed for chat_id={}", chat_id) + await self._handle_message( sender_id=user_id, chat_id=chat_id, diff --git a/tests/channels/test_qq_ack_message.py b/tests/channels/test_qq_ack_message.py new file mode 100644 index 000000000..0f3a2dbec --- /dev/null +++ b/tests/channels/test_qq_ack_message.py @@ -0,0 +1,172 @@ +"""Tests for QQ channel ack_message feature. + +Covers the four verification points from the PR: +1. C2C message: ack appears instantly +2. Group message: ack appears instantly +3. ack_message set to "": no ack sent +4. Custom ack_message text: correct text delivered +Each test also verifies that normal message processing is not blocked. +""" + +from types import SimpleNamespace + +import pytest + +try: + from nanobot.channels import qq + + QQ_AVAILABLE = getattr(qq, "QQ_AVAILABLE", False) +except ImportError: + QQ_AVAILABLE = False + +if not QQ_AVAILABLE: + pytest.skip("QQ dependencies not installed (qq-botpy)", allow_module_level=True) + +from nanobot.bus.queue import MessageBus +from nanobot.channels.qq import QQChannel, QQConfig + + +class _FakeApi: + def __init__(self) -> None: + self.c2c_calls: list[dict] = [] + self.group_calls: list[dict] = [] + + async def post_c2c_message(self, **kwargs) -> None: + self.c2c_calls.append(kwargs) + + async def post_group_message(self, **kwargs) -> None: + self.group_calls.append(kwargs) + + +class _FakeClient: + def __init__(self) -> None: + self.api = _FakeApi() + + +@pytest.mark.asyncio +async def test_ack_sent_on_c2c_message() -> None: + """Ack is sent immediately for C2C messages, then normal processing continues.""" + channel = QQChannel( + QQConfig( + app_id="app", + secret="secret", + allow_from=["*"], + ack_message="⏳ Processing...", + ), + MessageBus(), + ) + channel._client = _FakeClient() + + data = SimpleNamespace( + id="msg1", + content="hello", + author=SimpleNamespace(user_openid="user1"), + attachments=[], + ) + await channel._on_message(data, is_group=False) + + assert len(channel._client.api.c2c_calls) >= 1 + ack_call = channel._client.api.c2c_calls[0] + assert ack_call["content"] == "⏳ Processing..." + assert ack_call["openid"] == "user1" + assert ack_call["msg_id"] == "msg1" + assert ack_call["msg_type"] == 0 + + msg = await channel.bus.consume_inbound() + assert msg.content == "hello" + assert msg.sender_id == "user1" + + +@pytest.mark.asyncio +async def test_ack_sent_on_group_message() -> None: + """Ack is sent immediately for group messages, then normal processing continues.""" + channel = QQChannel( + QQConfig( + app_id="app", + secret="secret", + allow_from=["*"], + ack_message="⏳ Processing...", + ), + MessageBus(), + ) + channel._client = _FakeClient() + + data = SimpleNamespace( + id="msg2", + content="hello group", + group_openid="group123", + author=SimpleNamespace(member_openid="user1"), + attachments=[], + ) + await channel._on_message(data, is_group=True) + + assert len(channel._client.api.group_calls) >= 1 + ack_call = channel._client.api.group_calls[0] + assert ack_call["content"] == "⏳ Processing..." + assert ack_call["group_openid"] == "group123" + assert ack_call["msg_id"] == "msg2" + assert ack_call["msg_type"] == 0 + + msg = await channel.bus.consume_inbound() + assert msg.content == "hello group" + assert msg.chat_id == "group123" + + +@pytest.mark.asyncio +async def test_no_ack_when_ack_message_empty() -> None: + """Setting ack_message to empty string disables the ack entirely.""" + channel = QQChannel( + QQConfig( + app_id="app", + secret="secret", + allow_from=["*"], + ack_message="", + ), + MessageBus(), + ) + channel._client = _FakeClient() + + data = SimpleNamespace( + id="msg3", + content="hello", + author=SimpleNamespace(user_openid="user1"), + attachments=[], + ) + await channel._on_message(data, is_group=False) + + assert len(channel._client.api.c2c_calls) == 0 + assert len(channel._client.api.group_calls) == 0 + + msg = await channel.bus.consume_inbound() + assert msg.content == "hello" + + +@pytest.mark.asyncio +async def test_custom_ack_message_text() -> None: + """Custom Chinese ack_message text is delivered correctly.""" + custom = "正在处理中,请稍候..." + channel = QQChannel( + QQConfig( + app_id="app", + secret="secret", + allow_from=["*"], + ack_message=custom, + ), + MessageBus(), + ) + channel._client = _FakeClient() + + data = SimpleNamespace( + id="msg4", + content="test input", + author=SimpleNamespace(user_openid="user1"), + attachments=[], + ) + await channel._on_message(data, is_group=False) + + assert len(channel._client.api.c2c_calls) >= 1 + ack_call = channel._client.api.c2c_calls[0] + assert ack_call["content"] == custom + + msg = await channel.bus.consume_inbound() + assert msg.content == "test input" From 8b4d6b6512068519e5e887693efc96363c1257b5 Mon Sep 17 00:00:00 2001 From: Flo Date: Wed, 1 Apr 2026 09:42:18 +0300 Subject: [PATCH 11/22] fix(tools): strip blocks from message tool content (#2621) --- nanobot/agent/tools/message.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py index 3ac813248..520020735 100644 --- a/nanobot/agent/tools/message.py +++ b/nanobot/agent/tools/message.py @@ -84,6 +84,9 @@ class MessageTool(Tool): media: list[str] | None = None, **kwargs: Any ) -> str: + from nanobot.utils.helpers import strip_think + content = strip_think(content) + channel = channel or self._default_channel chat_id = chat_id or self._default_chat_id # Only inherit default message_id when targeting the same channel+chat. From 3ada54fa5d2eea8df33dbdad96f74e9e13dddbee Mon Sep 17 00:00:00 2001 From: Flo Date: Wed, 1 Apr 2026 11:47:41 +0300 Subject: [PATCH 12/22] fix(telegram): change drop_pending_updates to False on startup (#2686) --- nanobot/channels/telegram.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 72d60a19b..a6bd810f2 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -310,7 +310,7 @@ class TelegramChannel(BaseChannel): # Start polling (this runs until stopped) await self._app.updater.start_polling( allowed_updates=["message"], - drop_pending_updates=True # Ignore old messages on startup + drop_pending_updates=False # Process pending messages on startup ) # Keep running until stopped From 210643ed687f66c44e30a905c228119f14d70dba Mon Sep 17 00:00:00 2001 From: Lingao Meng Date: Fri, 3 Apr 2026 14:40:40 +0800 Subject: [PATCH 13/22] feat(provider): support reasoning_content in OpenAI compat provider Extract reasoning_content from both non-streaming and streaming responses in OpenAICompatProvider. Accumulate chunks during streaming and merge into LLMResponse, enabling reasoning chain display for models like MiMo and DeepSeek-R1. Signed-off-by: Lingao Meng --- nanobot/providers/openai_compat_provider.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index 3e0a34fbf..13b0eb78d 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -385,9 +385,13 @@ class OpenAICompatProvider(LLMProvider): content = self._extract_text_content( response_map.get("content") or response_map.get("output_text") ) + reasoning_content = self._extract_text_content( + response_map.get("reasoning_content") + ) if content is not None: return LLMResponse( content=content, + reasoning_content=reasoning_content, finish_reason=str(response_map.get("finish_reason") or "stop"), usage=self._extract_usage(response_map), ) @@ -482,6 +486,7 @@ class OpenAICompatProvider(LLMProvider): @classmethod def _parse_chunks(cls, chunks: list[Any]) -> LLMResponse: content_parts: list[str] = [] + reasoning_parts: list[str] = [] tc_bufs: dict[int, dict[str, Any]] = {} finish_reason = "stop" usage: dict[str, int] = {} @@ -535,6 +540,9 @@ class OpenAICompatProvider(LLMProvider): text = cls._extract_text_content(delta.get("content")) if text: content_parts.append(text) + text = cls._extract_text_content(delta.get("reasoning_content")) + if text: + reasoning_parts.append(text) for idx, tc in enumerate(delta.get("tool_calls") or []): _accum_tc(tc, idx) usage = cls._extract_usage(chunk_map) or usage @@ -549,6 +557,10 @@ class OpenAICompatProvider(LLMProvider): delta = choice.delta if delta and delta.content: content_parts.append(delta.content) + if delta: + reasoning = getattr(delta, "reasoning_content", None) + if reasoning: + reasoning_parts.append(reasoning) for tc in (delta.tool_calls or []) if delta else []: _accum_tc(tc, getattr(tc, "index", 0)) @@ -567,6 +579,7 @@ class OpenAICompatProvider(LLMProvider): ], finish_reason=finish_reason, usage=usage, + reasoning_content="".join(reasoning_parts) or None, ) @staticmethod @@ -630,6 +643,9 @@ class OpenAICompatProvider(LLMProvider): break chunks.append(chunk) if on_content_delta and chunk.choices: + text = getattr(chunk.choices[0].delta, "reasoning_content", None) + if text: + await on_content_delta(text) text = getattr(chunk.choices[0].delta, "content", None) if text: await on_content_delta(text) From a05f83da89f2718e7ffd0bf200120bb5705f0a68 Mon Sep 17 00:00:00 2001 From: Lingao Meng Date: Fri, 3 Apr 2026 15:12:55 +0800 Subject: [PATCH 14/22] test(providers): cover reasoning_content extraction in OpenAI compat provider Add regression tests for the non-streaming (_parse dict branch) and streaming (_parse_chunks dict and SDK-object branches) paths that extract reasoning_content, ensuring the field is populated when present and None when absent. Signed-off-by: Lingao Meng --- tests/providers/test_reasoning_content.py | 128 ++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 tests/providers/test_reasoning_content.py diff --git a/tests/providers/test_reasoning_content.py b/tests/providers/test_reasoning_content.py new file mode 100644 index 000000000..a58569143 --- /dev/null +++ b/tests/providers/test_reasoning_content.py @@ -0,0 +1,128 @@ +"""Tests for reasoning_content extraction in OpenAICompatProvider. + +Covers non-streaming (_parse) and streaming (_parse_chunks) paths for +providers that return a reasoning_content field (e.g. MiMo, DeepSeek-R1). +""" + +from types import SimpleNamespace +from unittest.mock import patch + +from nanobot.providers.openai_compat_provider import OpenAICompatProvider + + +# ── _parse: non-streaming ───────────────────────────────────────────────── + + +def test_parse_dict_extracts_reasoning_content() -> None: + """reasoning_content at message level is surfaced in LLMResponse.""" + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + provider = OpenAICompatProvider() + + response = { + "choices": [{ + "message": { + "content": "42", + "reasoning_content": "Let me think step by step…", + }, + "finish_reason": "stop", + }], + "usage": {"prompt_tokens": 5, "completion_tokens": 10, "total_tokens": 15}, + } + + result = provider._parse(response) + + assert result.content == "42" + assert result.reasoning_content == "Let me think step by step…" + + +def test_parse_dict_reasoning_content_none_when_absent() -> None: + """reasoning_content is None when the response doesn't include it.""" + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + provider = OpenAICompatProvider() + + response = { + "choices": [{ + "message": {"content": "hello"}, + "finish_reason": "stop", + }], + } + + result = provider._parse(response) + + assert result.reasoning_content is None + + +# ── _parse_chunks: streaming dict branch ───────────────────────────────── + + +def test_parse_chunks_dict_accumulates_reasoning_content() -> None: + """reasoning_content deltas in dict chunks are joined into one string.""" + chunks = [ + { + "choices": [{ + "finish_reason": None, + "delta": {"content": None, "reasoning_content": "Step 1. "}, + }], + }, + { + "choices": [{ + "finish_reason": None, + "delta": {"content": None, "reasoning_content": "Step 2."}, + }], + }, + { + "choices": [{ + "finish_reason": "stop", + "delta": {"content": "answer"}, + }], + }, + ] + + result = OpenAICompatProvider._parse_chunks(chunks) + + assert result.content == "answer" + assert result.reasoning_content == "Step 1. Step 2." + + +def test_parse_chunks_dict_reasoning_content_none_when_absent() -> None: + """reasoning_content is None when no chunk contains it.""" + chunks = [ + {"choices": [{"finish_reason": "stop", "delta": {"content": "hi"}}]}, + ] + + result = OpenAICompatProvider._parse_chunks(chunks) + + assert result.content == "hi" + assert result.reasoning_content is None + + +# ── _parse_chunks: streaming SDK-object branch ──────────────────────────── + + +def _make_reasoning_chunk(reasoning: str | None, content: str | None, finish: str | None): + delta = SimpleNamespace(content=content, reasoning_content=reasoning, tool_calls=None) + choice = SimpleNamespace(finish_reason=finish, delta=delta) + return SimpleNamespace(choices=[choice], usage=None) + + +def test_parse_chunks_sdk_accumulates_reasoning_content() -> None: + """reasoning_content on SDK delta objects is joined across chunks.""" + chunks = [ + _make_reasoning_chunk("Think… ", None, None), + _make_reasoning_chunk("Done.", None, None), + _make_reasoning_chunk(None, "result", "stop"), + ] + + result = OpenAICompatProvider._parse_chunks(chunks) + + assert result.content == "result" + assert result.reasoning_content == "Think… Done." + + +def test_parse_chunks_sdk_reasoning_content_none_when_absent() -> None: + """reasoning_content is None when SDK deltas carry no reasoning_content.""" + chunks = [_make_reasoning_chunk(None, "hello", "stop")] + + result = OpenAICompatProvider._parse_chunks(chunks) + + assert result.reasoning_content is None From ba7c07ccf2e81178c107367b048761ab5f4ff4f1 Mon Sep 17 00:00:00 2001 From: imfondof Date: Thu, 2 Apr 2026 16:42:47 +0800 Subject: [PATCH 15/22] fix(restart): send completion notice after channel is ready and unify runtime keys --- nanobot/cli/commands.py | 74 ++++++++++++++++++++++++++-- nanobot/command/builtin.py | 3 ++ nanobot/config/runtime_keys.py | 4 ++ tests/cli/test_restart_command.py | 81 ++++++++++++++++++++++++++++++- 4 files changed, 156 insertions(+), 6 deletions(-) create mode 100644 nanobot/config/runtime_keys.py diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index d611c2772..b1e4f056a 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -206,6 +206,57 @@ def _is_exit_command(command: str) -> bool: return command.lower() in EXIT_COMMANDS +def _parse_cli_session(session_id: str) -> tuple[str, str]: + """Split session id into (channel, chat_id).""" + if ":" in session_id: + return session_id.split(":", 1) + return "cli", session_id + + +def _should_show_cli_restart_notice( + restart_notify_channel: str, + restart_notify_chat_id: str, + session_id: str, +) -> bool: + """Return True when CLI should display restart-complete notice.""" + _, cli_chat_id = _parse_cli_session(session_id) + return restart_notify_channel == "cli" and ( + not restart_notify_chat_id or restart_notify_chat_id == cli_chat_id + ) + + +async def _notify_restart_done_when_channel_ready( + *, + bus, + channels, + channel: str, + chat_id: str, + timeout_s: float = 30.0, + poll_s: float = 0.25, +) -> bool: + """Wait for target channel readiness, then publish restart completion.""" + from nanobot.bus.events import OutboundMessage + + if not channel or not chat_id: + return False + if channel not in channels.enabled_channels: + return False + + waited = 0.0 + while waited <= timeout_s: + target = channels.get_channel(channel) + if target and target.is_running: + await bus.publish_outbound(OutboundMessage( + channel=channel, + chat_id=chat_id, + content="Restart completed.", + )) + return True + await asyncio.sleep(poll_s) + waited += poll_s + return False + + async def _read_interactive_input_async() -> str: """Read user input using prompt_toolkit (handles paste, history, display). @@ -598,6 +649,7 @@ def gateway( from nanobot.agent.loop import AgentLoop from nanobot.bus.queue import MessageBus from nanobot.channels.manager import ChannelManager + from nanobot.config.runtime_keys import RESTART_NOTIFY_CHANNEL_ENV, RESTART_NOTIFY_CHAT_ID_ENV from nanobot.cron.service import CronService from nanobot.cron.types import CronJob from nanobot.heartbeat.service import HeartbeatService @@ -696,6 +748,8 @@ def gateway( # Create channel manager channels = ChannelManager(config, bus) + restart_notify_channel = os.environ.pop(RESTART_NOTIFY_CHANNEL_ENV, "").strip() + restart_notify_chat_id = os.environ.pop(RESTART_NOTIFY_CHAT_ID_ENV, "").strip() def _pick_heartbeat_target() -> tuple[str, str]: """Pick a routable channel/chat target for heartbeat-triggered messages.""" @@ -772,6 +826,13 @@ def gateway( try: await cron.start() await heartbeat.start() + if restart_notify_channel and restart_notify_chat_id: + asyncio.create_task(_notify_restart_done_when_channel_ready( + bus=bus, + channels=channels, + channel=restart_notify_channel, + chat_id=restart_notify_chat_id, + )) await asyncio.gather( agent.run(), channels.start_all(), @@ -813,6 +874,7 @@ def agent( from nanobot.agent.loop import AgentLoop from nanobot.bus.queue import MessageBus + from nanobot.config.runtime_keys import RESTART_NOTIFY_CHANNEL_ENV, RESTART_NOTIFY_CHAT_ID_ENV from nanobot.cron.service import CronService config = _load_runtime_config(config, workspace) @@ -853,6 +915,13 @@ def agent( channels_config=config.channels, timezone=config.agents.defaults.timezone, ) + restart_notify_channel = os.environ.pop(RESTART_NOTIFY_CHANNEL_ENV, "").strip() + restart_notify_chat_id = os.environ.pop(RESTART_NOTIFY_CHAT_ID_ENV, "").strip() + + cli_channel, cli_chat_id = _parse_cli_session(session_id) + + if _should_show_cli_restart_notice(restart_notify_channel, restart_notify_chat_id, session_id): + _print_agent_response("Restart completed.", render_markdown=False) # Shared reference for progress callbacks _thinking: ThinkingSpinner | None = None @@ -891,11 +960,6 @@ def agent( _init_prompt_session() console.print(f"{__logo__} Interactive mode (type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit)\n") - if ":" in session_id: - cli_channel, cli_chat_id = session_id.split(":", 1) - else: - cli_channel, cli_chat_id = "cli", session_id - def _handle_signal(signum, frame): sig_name = signal.Signals(signum).name _restore_terminal() diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index 05d4fc163..f63a1e357 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -9,6 +9,7 @@ import sys from nanobot import __version__ from nanobot.bus.events import OutboundMessage from nanobot.command.router import CommandContext, CommandRouter +from nanobot.config.runtime_keys import RESTART_NOTIFY_CHANNEL_ENV, RESTART_NOTIFY_CHAT_ID_ENV from nanobot.utils.helpers import build_status_content @@ -35,6 +36,8 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage: async def cmd_restart(ctx: CommandContext) -> OutboundMessage: """Restart the process in-place via os.execv.""" msg = ctx.msg + os.environ[RESTART_NOTIFY_CHANNEL_ENV] = msg.channel + os.environ[RESTART_NOTIFY_CHAT_ID_ENV] = msg.chat_id async def _do_restart(): await asyncio.sleep(1) diff --git a/nanobot/config/runtime_keys.py b/nanobot/config/runtime_keys.py new file mode 100644 index 000000000..2dc6c9234 --- /dev/null +++ b/nanobot/config/runtime_keys.py @@ -0,0 +1,4 @@ +"""Runtime environment variable keys shared across components.""" + +RESTART_NOTIFY_CHANNEL_ENV = "NANOBOT_RESTART_NOTIFY_CHANNEL" +RESTART_NOTIFY_CHAT_ID_ENV = "NANOBOT_RESTART_NOTIFY_CHAT_ID" diff --git a/tests/cli/test_restart_command.py b/tests/cli/test_restart_command.py index 6efcdad0d..16b3aaa48 100644 --- a/tests/cli/test_restart_command.py +++ b/tests/cli/test_restart_command.py @@ -3,7 +3,9 @@ from __future__ import annotations import asyncio +import os import time +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -35,15 +37,19 @@ class TestRestartCommand: @pytest.mark.asyncio async def test_restart_sends_message_and_calls_execv(self): from nanobot.command.builtin import cmd_restart + from nanobot.config.runtime_keys import RESTART_NOTIFY_CHANNEL_ENV, RESTART_NOTIFY_CHAT_ID_ENV from nanobot.command.router import CommandContext loop, bus = _make_loop() msg = InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/restart") ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/restart", loop=loop) - with patch("nanobot.command.builtin.os.execv") as mock_execv: + with patch.dict(os.environ, {}, clear=False), \ + patch("nanobot.command.builtin.os.execv") as mock_execv: out = await cmd_restart(ctx) assert "Restarting" in out.content + assert os.environ.get(RESTART_NOTIFY_CHANNEL_ENV) == "cli" + assert os.environ.get(RESTART_NOTIFY_CHAT_ID_ENV) == "direct" await asyncio.sleep(1.5) mock_execv.assert_called_once() @@ -190,3 +196,76 @@ class TestRestartCommand: assert response is not None assert response.metadata == {"render_as": "text"} + + +@pytest.mark.asyncio +async def test_notify_restart_done_waits_until_channel_running() -> None: + from nanobot.bus.queue import MessageBus + from nanobot.cli.commands import _notify_restart_done_when_channel_ready + + bus = MessageBus() + channel = SimpleNamespace(is_running=False) + + class DummyChannels: + enabled_channels = ["feishu"] + + @staticmethod + def get_channel(name: str): + return channel if name == "feishu" else None + + async def _mark_running() -> None: + await asyncio.sleep(0.02) + channel.is_running = True + + marker = asyncio.create_task(_mark_running()) + sent = await _notify_restart_done_when_channel_ready( + bus=bus, + channels=DummyChannels(), + channel="feishu", + chat_id="oc_123", + timeout_s=0.2, + poll_s=0.01, + ) + await marker + + assert sent is True + out = await asyncio.wait_for(bus.consume_outbound(), timeout=0.1) + assert out.channel == "feishu" + assert out.chat_id == "oc_123" + assert out.content == "Restart completed." + + +@pytest.mark.asyncio +async def test_notify_restart_done_times_out_when_channel_not_running() -> None: + from nanobot.bus.queue import MessageBus + from nanobot.cli.commands import _notify_restart_done_when_channel_ready + + bus = MessageBus() + channel = SimpleNamespace(is_running=False) + + class DummyChannels: + enabled_channels = ["feishu"] + + @staticmethod + def get_channel(name: str): + return channel if name == "feishu" else None + + sent = await _notify_restart_done_when_channel_ready( + bus=bus, + channels=DummyChannels(), + channel="feishu", + chat_id="oc_123", + timeout_s=0.05, + poll_s=0.01, + ) + assert sent is False + assert bus.outbound_size == 0 + + +def test_should_show_cli_restart_notice() -> None: + from nanobot.cli.commands import _should_show_cli_restart_notice + + assert _should_show_cli_restart_notice("cli", "direct", "cli:direct") is True + assert _should_show_cli_restart_notice("cli", "", "cli:direct") is True + assert _should_show_cli_restart_notice("cli", "other", "cli:direct") is False + assert _should_show_cli_restart_notice("feishu", "oc_123", "cli:direct") is False From 896d5786775608ddd57eae3bf324cc6299ea4ccc Mon Sep 17 00:00:00 2001 From: imfondof Date: Fri, 3 Apr 2026 00:44:17 +0800 Subject: [PATCH 16/22] fix(restart): show restart completion with elapsed time across channels --- nanobot/channels/manager.py | 20 ++++++ nanobot/cli/commands.py | 85 +++++--------------------- nanobot/command/builtin.py | 5 +- nanobot/config/runtime_keys.py | 4 -- nanobot/utils/restart.py | 58 ++++++++++++++++++ tests/channels/test_channel_plugins.py | 28 +++++++++ tests/cli/test_restart_command.py | 81 ++---------------------- tests/utils/test_restart.py | 49 +++++++++++++++ 8 files changed, 179 insertions(+), 151 deletions(-) delete mode 100644 nanobot/config/runtime_keys.py create mode 100644 nanobot/utils/restart.py create mode 100644 tests/utils/test_restart.py diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 0d6232251..1f26f4d7a 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -11,6 +11,7 @@ from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.schema import Config +from nanobot.utils.restart import consume_restart_notice_from_env, format_restart_completed_message # Retry delays for message sending (exponential backoff: 1s, 2s, 4s) _SEND_RETRY_DELAYS = (1, 2, 4) @@ -91,9 +92,28 @@ class ChannelManager: logger.info("Starting {} channel...", name) tasks.append(asyncio.create_task(self._start_channel(name, channel))) + self._notify_restart_done_if_needed() + # Wait for all to complete (they should run forever) await asyncio.gather(*tasks, return_exceptions=True) + def _notify_restart_done_if_needed(self) -> None: + """Send restart completion message when runtime env markers are present.""" + notice = consume_restart_notice_from_env() + if not notice: + return + target = self.channels.get(notice.channel) + if not target: + return + asyncio.create_task(self._send_with_retry( + target, + OutboundMessage( + channel=notice.channel, + chat_id=notice.chat_id, + content=format_restart_completed_message(notice.started_at_raw), + ), + )) + async def stop_all(self) -> None: """Stop all channels and the dispatcher.""" logger.info("Stopping all channels...") diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index b1e4f056a..4dcf3873f 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -37,6 +37,11 @@ from nanobot.cli.stream import StreamRenderer, ThinkingSpinner from nanobot.config.paths import get_workspace_path, is_default_workspace from nanobot.config.schema import Config from nanobot.utils.helpers import sync_workspace_templates +from nanobot.utils.restart import ( + consume_restart_notice_from_env, + format_restart_completed_message, + should_show_cli_restart_notice, +) app = typer.Typer( name="nanobot", @@ -206,57 +211,6 @@ def _is_exit_command(command: str) -> bool: return command.lower() in EXIT_COMMANDS -def _parse_cli_session(session_id: str) -> tuple[str, str]: - """Split session id into (channel, chat_id).""" - if ":" in session_id: - return session_id.split(":", 1) - return "cli", session_id - - -def _should_show_cli_restart_notice( - restart_notify_channel: str, - restart_notify_chat_id: str, - session_id: str, -) -> bool: - """Return True when CLI should display restart-complete notice.""" - _, cli_chat_id = _parse_cli_session(session_id) - return restart_notify_channel == "cli" and ( - not restart_notify_chat_id or restart_notify_chat_id == cli_chat_id - ) - - -async def _notify_restart_done_when_channel_ready( - *, - bus, - channels, - channel: str, - chat_id: str, - timeout_s: float = 30.0, - poll_s: float = 0.25, -) -> bool: - """Wait for target channel readiness, then publish restart completion.""" - from nanobot.bus.events import OutboundMessage - - if not channel or not chat_id: - return False - if channel not in channels.enabled_channels: - return False - - waited = 0.0 - while waited <= timeout_s: - target = channels.get_channel(channel) - if target and target.is_running: - await bus.publish_outbound(OutboundMessage( - channel=channel, - chat_id=chat_id, - content="Restart completed.", - )) - return True - await asyncio.sleep(poll_s) - waited += poll_s - return False - - async def _read_interactive_input_async() -> str: """Read user input using prompt_toolkit (handles paste, history, display). @@ -649,7 +603,6 @@ def gateway( from nanobot.agent.loop import AgentLoop from nanobot.bus.queue import MessageBus from nanobot.channels.manager import ChannelManager - from nanobot.config.runtime_keys import RESTART_NOTIFY_CHANNEL_ENV, RESTART_NOTIFY_CHAT_ID_ENV from nanobot.cron.service import CronService from nanobot.cron.types import CronJob from nanobot.heartbeat.service import HeartbeatService @@ -748,8 +701,6 @@ def gateway( # Create channel manager channels = ChannelManager(config, bus) - restart_notify_channel = os.environ.pop(RESTART_NOTIFY_CHANNEL_ENV, "").strip() - restart_notify_chat_id = os.environ.pop(RESTART_NOTIFY_CHAT_ID_ENV, "").strip() def _pick_heartbeat_target() -> tuple[str, str]: """Pick a routable channel/chat target for heartbeat-triggered messages.""" @@ -826,13 +777,6 @@ def gateway( try: await cron.start() await heartbeat.start() - if restart_notify_channel and restart_notify_chat_id: - asyncio.create_task(_notify_restart_done_when_channel_ready( - bus=bus, - channels=channels, - channel=restart_notify_channel, - chat_id=restart_notify_chat_id, - )) await asyncio.gather( agent.run(), channels.start_all(), @@ -874,7 +818,6 @@ def agent( from nanobot.agent.loop import AgentLoop from nanobot.bus.queue import MessageBus - from nanobot.config.runtime_keys import RESTART_NOTIFY_CHANNEL_ENV, RESTART_NOTIFY_CHAT_ID_ENV from nanobot.cron.service import CronService config = _load_runtime_config(config, workspace) @@ -915,13 +858,12 @@ def agent( channels_config=config.channels, timezone=config.agents.defaults.timezone, ) - restart_notify_channel = os.environ.pop(RESTART_NOTIFY_CHANNEL_ENV, "").strip() - restart_notify_chat_id = os.environ.pop(RESTART_NOTIFY_CHAT_ID_ENV, "").strip() - - cli_channel, cli_chat_id = _parse_cli_session(session_id) - - if _should_show_cli_restart_notice(restart_notify_channel, restart_notify_chat_id, session_id): - _print_agent_response("Restart completed.", render_markdown=False) + restart_notice = consume_restart_notice_from_env() + if restart_notice and should_show_cli_restart_notice(restart_notice, session_id): + _print_agent_response( + format_restart_completed_message(restart_notice.started_at_raw), + render_markdown=False, + ) # Shared reference for progress callbacks _thinking: ThinkingSpinner | None = None @@ -960,6 +902,11 @@ def agent( _init_prompt_session() console.print(f"{__logo__} Interactive mode (type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit)\n") + if ":" in session_id: + cli_channel, cli_chat_id = session_id.split(":", 1) + else: + cli_channel, cli_chat_id = "cli", session_id + def _handle_signal(signum, frame): sig_name = signal.Signals(signum).name _restore_terminal() diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index f63a1e357..fa8dd693b 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -9,8 +9,8 @@ import sys from nanobot import __version__ from nanobot.bus.events import OutboundMessage from nanobot.command.router import CommandContext, CommandRouter -from nanobot.config.runtime_keys import RESTART_NOTIFY_CHANNEL_ENV, RESTART_NOTIFY_CHAT_ID_ENV from nanobot.utils.helpers import build_status_content +from nanobot.utils.restart import set_restart_notice_to_env async def cmd_stop(ctx: CommandContext) -> OutboundMessage: @@ -36,8 +36,7 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage: async def cmd_restart(ctx: CommandContext) -> OutboundMessage: """Restart the process in-place via os.execv.""" msg = ctx.msg - os.environ[RESTART_NOTIFY_CHANNEL_ENV] = msg.channel - os.environ[RESTART_NOTIFY_CHAT_ID_ENV] = msg.chat_id + set_restart_notice_to_env(channel=msg.channel, chat_id=msg.chat_id) async def _do_restart(): await asyncio.sleep(1) diff --git a/nanobot/config/runtime_keys.py b/nanobot/config/runtime_keys.py deleted file mode 100644 index 2dc6c9234..000000000 --- a/nanobot/config/runtime_keys.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Runtime environment variable keys shared across components.""" - -RESTART_NOTIFY_CHANNEL_ENV = "NANOBOT_RESTART_NOTIFY_CHANNEL" -RESTART_NOTIFY_CHAT_ID_ENV = "NANOBOT_RESTART_NOTIFY_CHAT_ID" diff --git a/nanobot/utils/restart.py b/nanobot/utils/restart.py new file mode 100644 index 000000000..35b8cced5 --- /dev/null +++ b/nanobot/utils/restart.py @@ -0,0 +1,58 @@ +"""Helpers for restart notification messages.""" + +from __future__ import annotations + +import os +import time +from dataclasses import dataclass + +RESTART_NOTIFY_CHANNEL_ENV = "NANOBOT_RESTART_NOTIFY_CHANNEL" +RESTART_NOTIFY_CHAT_ID_ENV = "NANOBOT_RESTART_NOTIFY_CHAT_ID" +RESTART_STARTED_AT_ENV = "NANOBOT_RESTART_STARTED_AT" + + +@dataclass(frozen=True) +class RestartNotice: + channel: str + chat_id: str + started_at_raw: str + + +def format_restart_completed_message(started_at_raw: str) -> str: + """Build restart completion text and include elapsed time when available.""" + elapsed_suffix = "" + if started_at_raw: + try: + elapsed_s = max(0.0, time.time() - float(started_at_raw)) + elapsed_suffix = f" in {elapsed_s:.1f}s" + except ValueError: + pass + return f"Restart completed{elapsed_suffix}." + + +def set_restart_notice_to_env(*, channel: str, chat_id: str) -> None: + """Write restart notice env values for the next process.""" + os.environ[RESTART_NOTIFY_CHANNEL_ENV] = channel + os.environ[RESTART_NOTIFY_CHAT_ID_ENV] = chat_id + os.environ[RESTART_STARTED_AT_ENV] = str(time.time()) + + +def consume_restart_notice_from_env() -> RestartNotice | None: + """Read and clear restart notice env values once for this process.""" + channel = os.environ.pop(RESTART_NOTIFY_CHANNEL_ENV, "").strip() + chat_id = os.environ.pop(RESTART_NOTIFY_CHAT_ID_ENV, "").strip() + started_at_raw = os.environ.pop(RESTART_STARTED_AT_ENV, "").strip() + if not (channel and chat_id): + return None + return RestartNotice(channel=channel, chat_id=chat_id, started_at_raw=started_at_raw) + + +def should_show_cli_restart_notice(notice: RestartNotice, session_id: str) -> bool: + """Return True when a restart notice should be shown in this CLI session.""" + if notice.channel != "cli": + return False + if ":" in session_id: + _, cli_chat_id = session_id.split(":", 1) + else: + cli_chat_id = session_id + return not notice.chat_id or notice.chat_id == cli_chat_id diff --git a/tests/channels/test_channel_plugins.py b/tests/channels/test_channel_plugins.py index 4cf4fab21..8bb95b532 100644 --- a/tests/channels/test_channel_plugins.py +++ b/tests/channels/test_channel_plugins.py @@ -13,6 +13,7 @@ from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.channels.manager import ChannelManager from nanobot.config.schema import ChannelsConfig +from nanobot.utils.restart import RestartNotice # --------------------------------------------------------------------------- @@ -929,3 +930,30 @@ async def test_start_all_creates_dispatch_task(): # Dispatch task should have been created assert mgr._dispatch_task is not None + +@pytest.mark.asyncio +async def test_notify_restart_done_enqueues_outbound_message(): + """Restart notice should schedule send_with_retry for target channel.""" + fake_config = SimpleNamespace( + channels=ChannelsConfig(), + providers=SimpleNamespace(groq=SimpleNamespace(api_key="")), + ) + + mgr = ChannelManager.__new__(ChannelManager) + mgr.config = fake_config + mgr.bus = MessageBus() + mgr.channels = {"feishu": _StartableChannel(fake_config, mgr.bus)} + mgr._dispatch_task = None + mgr._send_with_retry = AsyncMock() + + notice = RestartNotice(channel="feishu", chat_id="oc_123", started_at_raw="100.0") + with patch("nanobot.channels.manager.consume_restart_notice_from_env", return_value=notice): + mgr._notify_restart_done_if_needed() + + await asyncio.sleep(0) + mgr._send_with_retry.assert_awaited_once() + sent_channel, sent_msg = mgr._send_with_retry.await_args.args + assert sent_channel is mgr.channels["feishu"] + assert sent_msg.channel == "feishu" + assert sent_msg.chat_id == "oc_123" + assert sent_msg.content.startswith("Restart completed") diff --git a/tests/cli/test_restart_command.py b/tests/cli/test_restart_command.py index 16b3aaa48..8ea30f684 100644 --- a/tests/cli/test_restart_command.py +++ b/tests/cli/test_restart_command.py @@ -5,7 +5,6 @@ from __future__ import annotations import asyncio import os import time -from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -37,8 +36,12 @@ class TestRestartCommand: @pytest.mark.asyncio async def test_restart_sends_message_and_calls_execv(self): from nanobot.command.builtin import cmd_restart - from nanobot.config.runtime_keys import RESTART_NOTIFY_CHANNEL_ENV, RESTART_NOTIFY_CHAT_ID_ENV from nanobot.command.router import CommandContext + from nanobot.utils.restart import ( + RESTART_NOTIFY_CHANNEL_ENV, + RESTART_NOTIFY_CHAT_ID_ENV, + RESTART_STARTED_AT_ENV, + ) loop, bus = _make_loop() msg = InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/restart") @@ -50,6 +53,7 @@ class TestRestartCommand: assert "Restarting" in out.content assert os.environ.get(RESTART_NOTIFY_CHANNEL_ENV) == "cli" assert os.environ.get(RESTART_NOTIFY_CHAT_ID_ENV) == "direct" + assert os.environ.get(RESTART_STARTED_AT_ENV) await asyncio.sleep(1.5) mock_execv.assert_called_once() @@ -196,76 +200,3 @@ class TestRestartCommand: assert response is not None assert response.metadata == {"render_as": "text"} - - -@pytest.mark.asyncio -async def test_notify_restart_done_waits_until_channel_running() -> None: - from nanobot.bus.queue import MessageBus - from nanobot.cli.commands import _notify_restart_done_when_channel_ready - - bus = MessageBus() - channel = SimpleNamespace(is_running=False) - - class DummyChannels: - enabled_channels = ["feishu"] - - @staticmethod - def get_channel(name: str): - return channel if name == "feishu" else None - - async def _mark_running() -> None: - await asyncio.sleep(0.02) - channel.is_running = True - - marker = asyncio.create_task(_mark_running()) - sent = await _notify_restart_done_when_channel_ready( - bus=bus, - channels=DummyChannels(), - channel="feishu", - chat_id="oc_123", - timeout_s=0.2, - poll_s=0.01, - ) - await marker - - assert sent is True - out = await asyncio.wait_for(bus.consume_outbound(), timeout=0.1) - assert out.channel == "feishu" - assert out.chat_id == "oc_123" - assert out.content == "Restart completed." - - -@pytest.mark.asyncio -async def test_notify_restart_done_times_out_when_channel_not_running() -> None: - from nanobot.bus.queue import MessageBus - from nanobot.cli.commands import _notify_restart_done_when_channel_ready - - bus = MessageBus() - channel = SimpleNamespace(is_running=False) - - class DummyChannels: - enabled_channels = ["feishu"] - - @staticmethod - def get_channel(name: str): - return channel if name == "feishu" else None - - sent = await _notify_restart_done_when_channel_ready( - bus=bus, - channels=DummyChannels(), - channel="feishu", - chat_id="oc_123", - timeout_s=0.05, - poll_s=0.01, - ) - assert sent is False - assert bus.outbound_size == 0 - - -def test_should_show_cli_restart_notice() -> None: - from nanobot.cli.commands import _should_show_cli_restart_notice - - assert _should_show_cli_restart_notice("cli", "direct", "cli:direct") is True - assert _should_show_cli_restart_notice("cli", "", "cli:direct") is True - assert _should_show_cli_restart_notice("cli", "other", "cli:direct") is False - assert _should_show_cli_restart_notice("feishu", "oc_123", "cli:direct") is False diff --git a/tests/utils/test_restart.py b/tests/utils/test_restart.py new file mode 100644 index 000000000..48124d383 --- /dev/null +++ b/tests/utils/test_restart.py @@ -0,0 +1,49 @@ +"""Tests for restart notice helpers.""" + +from __future__ import annotations + +import os + +from nanobot.utils.restart import ( + RestartNotice, + consume_restart_notice_from_env, + format_restart_completed_message, + set_restart_notice_to_env, + should_show_cli_restart_notice, +) + + +def test_set_and_consume_restart_notice_env_roundtrip(monkeypatch): + monkeypatch.delenv("NANOBOT_RESTART_NOTIFY_CHANNEL", raising=False) + monkeypatch.delenv("NANOBOT_RESTART_NOTIFY_CHAT_ID", raising=False) + monkeypatch.delenv("NANOBOT_RESTART_STARTED_AT", raising=False) + + set_restart_notice_to_env(channel="feishu", chat_id="oc_123") + + notice = consume_restart_notice_from_env() + assert notice is not None + assert notice.channel == "feishu" + assert notice.chat_id == "oc_123" + assert notice.started_at_raw + + # Consumed values should be cleared from env. + assert consume_restart_notice_from_env() is None + assert "NANOBOT_RESTART_NOTIFY_CHANNEL" not in os.environ + assert "NANOBOT_RESTART_NOTIFY_CHAT_ID" not in os.environ + assert "NANOBOT_RESTART_STARTED_AT" not in os.environ + + +def test_format_restart_completed_message_with_elapsed(monkeypatch): + monkeypatch.setattr("nanobot.utils.restart.time.time", lambda: 102.0) + assert format_restart_completed_message("100.0") == "Restart completed in 2.0s." + + +def test_should_show_cli_restart_notice(): + notice = RestartNotice(channel="cli", chat_id="direct", started_at_raw="100") + assert should_show_cli_restart_notice(notice, "cli:direct") is True + assert should_show_cli_restart_notice(notice, "cli:other") is False + assert should_show_cli_restart_notice(notice, "direct") is True + + non_cli = RestartNotice(channel="feishu", chat_id="oc_1", started_at_raw="100") + assert should_show_cli_restart_notice(non_cli, "cli:direct") is False + From 400f8eb38e85fefcdcfb1238ac312368428b0769 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Fri, 3 Apr 2026 18:44:46 +0000 Subject: [PATCH 17/22] docs: update web search configuration information --- README.md | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index da7346b38..7ca22fd23 100644 --- a/README.md +++ b/README.md @@ -1217,17 +1217,30 @@ When a channel send operation raises an error, nanobot retries with exponential nanobot supports multiple web search providers. Configure in `~/.nanobot/config.json` under `tools.web.search`. +By default, web tools are enabled and web search uses `duckduckgo`, so search works out of the box without an API key. + +If you want to disable all built-in web tools entirely, set `tools.web.enable` to `false`. This removes both `web_search` and `web_fetch` from the tool list sent to the LLM. + | Provider | Config fields | Env var fallback | Free | |----------|--------------|------------------|------| -| `brave` (default) | `apiKey` | `BRAVE_API_KEY` | No | +| `brave` | `apiKey` | `BRAVE_API_KEY` | No | | `tavily` | `apiKey` | `TAVILY_API_KEY` | No | | `jina` | `apiKey` | `JINA_API_KEY` | Free tier (10M tokens) | | `searxng` | `baseUrl` | `SEARXNG_BASE_URL` | Yes (self-hosted) | -| `duckduckgo` | — | — | Yes | +| `duckduckgo` (default) | — | — | Yes | -When credentials are missing, nanobot automatically falls back to DuckDuckGo. +**Disable all built-in web tools:** +```json +{ + "tools": { + "web": { + "enable": false + } + } +} +``` -**Brave** (default): +**Brave:** ```json { "tools": { @@ -1298,7 +1311,14 @@ When credentials are missing, nanobot automatically falls back to DuckDuckGo. | Option | Type | Default | Description | |--------|------|---------|-------------| -| `provider` | string | `"brave"` | Search backend: `brave`, `tavily`, `jina`, `searxng`, `duckduckgo` | +| `enable` | boolean | `true` | Enable or disable all built-in web tools (`web_search` + `web_fetch`) | +| `proxy` | string or null | `null` | Proxy for all web requests, for example `http://127.0.0.1:7890` | + +#### `tools.web.search` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `provider` | string | `"duckduckgo"` | Search backend: `brave`, `tavily`, `jina`, `searxng`, `duckduckgo` | | `apiKey` | string | `""` | API key for Brave or Tavily | | `baseUrl` | string | `""` | Base URL for SearXNG | | `maxResults` | integer | `5` | Results per search (1–10) | From ca3b918cf0163daf149394d6f816c957f4b93992 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Fri, 3 Apr 2026 18:57:44 +0000 Subject: [PATCH 18/22] docs: clarify retry behavior and web search defaults --- README.md | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 7ca22fd23..7816191af 100644 --- a/README.md +++ b/README.md @@ -1196,16 +1196,23 @@ Global settings that apply to all channels. Configure under the `channels` secti #### Retry Behavior -When a channel send operation raises an error, nanobot retries with exponential backoff: +Retry is intentionally simple. -- **Attempt 1**: Initial send -- **Attempts 2-4**: Retry delays are 1s, 2s, 4s -- **Attempts 5+**: Retry delay caps at 4s -- **Transient failures** (network hiccups, temporary API limits): Retry usually succeeds -- **Permanent failures** (invalid token, channel banned): All retries fail +When a channel `send()` raises, nanobot retries at the channel-manager layer. By default, `channels.sendMaxRetries` is `3`, and that count includes the initial send. + +- **Attempt 1**: Send immediately +- **Attempt 2**: Retry after `1s` +- **Attempt 3**: Retry after `2s` +- **Higher retry budgets**: Backoff continues as `1s`, `2s`, `4s`, then stays capped at `4s` +- **Transient failures**: Network hiccups and temporary API limits often recover on the next attempt +- **Permanent failures**: Invalid tokens, revoked access, or banned channels will exhaust the retry budget and fail cleanly > [!NOTE] -> When a channel is completely unavailable, there's no way to notify the user since we cannot reach them through that channel. Monitor logs for "Failed to send to {channel} after N attempts" to detect persistent delivery failures. +> This design is deliberate: channel implementations should raise on delivery failure, and the channel manager owns the shared retry policy. +> +> Some channels may still apply small API-specific retries internally. For example, Telegram separately retries timeout and flood-control errors before surfacing a final failure to the manager. +> +> If a channel is completely unreachable, nanobot cannot notify the user through that same channel. Watch logs for `Failed to send to {channel} after N attempts` to spot persistent delivery failures. ### Web Search From bc879386fe51e85a03b1a23f4e2336d216961490 Mon Sep 17 00:00:00 2001 From: Shiniese <135589327+Shiniese@users.noreply.github.com> Date: Wed, 1 Apr 2026 15:45:02 +0800 Subject: [PATCH 19/22] fix(shell): allow media directory access when restrict_to_workspace is enabled --- nanobot/agent/tools/shell.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index dd3a44335..77803e8b3 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -183,7 +183,16 @@ class ExecTool(Tool): p = Path(expanded).expanduser().resolve() except Exception: continue - if p.is_absolute() and cwd_path not in p.parents and p != cwd_path: + + from nanobot.config.paths import get_runtime_subdir + media_path = get_runtime_subdir("media").resolve() + + if (p.is_absolute() + and cwd_path not in p.parents + and p != cwd_path + and media_path not in p.parents + and p != media_path + ): return "Error: Command blocked by safety guard (path outside working dir)" return None From 624f6078729fa3622416796a3eb08e1e9d7b608c Mon Sep 17 00:00:00 2001 From: Shiniese <135589327+Shiniese@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:19:53 +0800 Subject: [PATCH 20/22] fix(filesystem): add media directory exemption to filesystem tool path checks --- nanobot/agent/tools/filesystem.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index d4094e7f3..a0e470fa9 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -21,7 +21,9 @@ def _resolve_path( p = workspace / p resolved = p.resolve() if allowed_dir: - all_dirs = [allowed_dir] + (extra_allowed_dirs or []) + from nanobot.config.paths import get_runtime_subdir + media_path = get_runtime_subdir("media").resolve() + all_dirs = [allowed_dir] + [media_path] + (extra_allowed_dirs or []) if not any(_is_under(resolved, d) for d in all_dirs): raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}") return resolved From 84c4ba7609adf6e8c8ccc989d6a1b51cc26792f9 Mon Sep 17 00:00:00 2001 From: Shiniese <135589327+Shiniese@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:30:42 +0800 Subject: [PATCH 21/22] refactor: use unified get_media_dir() to get media path --- nanobot/agent/tools/filesystem.py | 4 ++-- nanobot/agent/tools/shell.py | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index a0e470fa9..e3a8fecaf 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -7,6 +7,7 @@ from typing import Any from nanobot.agent.tools.base import Tool from nanobot.utils.helpers import build_image_content_blocks, detect_image_mime +from nanobot.config.paths import get_media_dir def _resolve_path( @@ -21,8 +22,7 @@ def _resolve_path( p = workspace / p resolved = p.resolve() if allowed_dir: - from nanobot.config.paths import get_runtime_subdir - media_path = get_runtime_subdir("media").resolve() + media_path = get_media_dir().resolve() all_dirs = [allowed_dir] + [media_path] + (extra_allowed_dirs or []) if not any(_is_under(resolved, d) for d in all_dirs): raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}") diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index 77803e8b3..c987a5f99 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -10,6 +10,7 @@ from typing import Any from loguru import logger from nanobot.agent.tools.base import Tool +from nanobot.config.paths import get_media_dir class ExecTool(Tool): @@ -184,9 +185,7 @@ class ExecTool(Tool): except Exception: continue - from nanobot.config.paths import get_runtime_subdir - media_path = get_runtime_subdir("media").resolve() - + media_path = get_media_dir().resolve() if (p.is_absolute() and cwd_path not in p.parents and p != cwd_path From 9840270f7fe2fe9dbad8776ba7575f346f602b09 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Fri, 3 Apr 2026 19:00:53 +0000 Subject: [PATCH 22/22] test(tools): cover media dir access under workspace restriction Made-with: Cursor --- tests/tools/test_filesystem_tools.py | 16 ++++++++++++++++ tests/tools/test_tool_validation.py | 13 +++++++++++++ 2 files changed, 29 insertions(+) diff --git a/tests/tools/test_filesystem_tools.py b/tests/tools/test_filesystem_tools.py index ca6629edb..21ecffe58 100644 --- a/tests/tools/test_filesystem_tools.py +++ b/tests/tools/test_filesystem_tools.py @@ -321,6 +321,22 @@ class TestWorkspaceRestriction: assert "Test Skill" in result assert "Error" not in result + @pytest.mark.asyncio + async def test_read_allowed_in_media_dir(self, tmp_path, monkeypatch): + workspace = tmp_path / "ws" + workspace.mkdir() + media_dir = tmp_path / "media" + media_dir.mkdir() + media_file = media_dir / "photo.txt" + media_file.write_text("shared media", encoding="utf-8") + + monkeypatch.setattr("nanobot.agent.tools.filesystem.get_media_dir", lambda: media_dir) + + tool = ReadFileTool(workspace=workspace, allowed_dir=workspace) + result = await tool.execute(path=str(media_file)) + assert "shared media" in result + assert "Error" not in result + @pytest.mark.asyncio async def test_extra_dirs_does_not_widen_write(self, tmp_path): from nanobot.agent.tools.filesystem import WriteFileTool diff --git a/tests/tools/test_tool_validation.py b/tests/tools/test_tool_validation.py index 98a3dc903..0fd15e383 100644 --- a/tests/tools/test_tool_validation.py +++ b/tests/tools/test_tool_validation.py @@ -142,6 +142,19 @@ def test_exec_guard_blocks_quoted_home_path_outside_workspace(tmp_path) -> None: assert error == "Error: Command blocked by safety guard (path outside working dir)" +def test_exec_guard_allows_media_path_outside_workspace(tmp_path, monkeypatch) -> None: + media_dir = tmp_path / "media" + media_dir.mkdir() + media_file = media_dir / "photo.jpg" + media_file.write_text("ok", encoding="utf-8") + + monkeypatch.setattr("nanobot.agent.tools.shell.get_media_dir", lambda: media_dir) + + tool = ExecTool(restrict_to_workspace=True) + error = tool._guard_command(f'cat "{media_file}"', str(tmp_path / "workspace")) + assert error is None + + def test_exec_guard_blocks_windows_drive_root_outside_workspace(monkeypatch) -> None: import nanobot.agent.tools.shell as shell_mod