From cf2ed8a6a011bad6bf25f182682b913b6664be38 Mon Sep 17 00:00:00 2001
From: gaoyiman
Date: Thu, 26 Feb 2026 16:22:24 +0800
Subject: [PATCH 01/36] 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/36] 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 85c56d7410ab4eed78ec70d75489cf453afcfbb3 Mon Sep 17 00:00:00 2001
From: Renato Machado
Date: Mon, 9 Mar 2026 01:37:35 +0000
Subject: [PATCH 03/36] feat: add "restart" command
---
nanobot/agent/loop.py | 11 +++++++++++
nanobot/channels/telegram.py | 2 ++
2 files changed, 13 insertions(+)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index ca9a06e..5311921 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -5,6 +5,8 @@ from __future__ import annotations
import asyncio
import json
import re
+import os
+import sys
import weakref
from contextlib import AsyncExitStack
from pathlib import Path
@@ -392,6 +394,15 @@ class AgentLoop:
if cmd == "/help":
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="🐈 nanobot commands:\n/new — Start a new conversation\n/stop — Stop the current task\n/help — Show available commands")
+ if cmd == "/restart":
+ await self.bus.publish_outbound(OutboundMessage(
+ channel=msg.channel, chat_id=msg.chat_id, content="🔄 Restarting..."
+ ))
+ async def _r():
+ await asyncio.sleep(1)
+ os.execv(sys.executable, [sys.executable] + sys.argv)
+ asyncio.create_task(_r())
+ return None
unconsolidated = len(session.messages) - session.last_consolidated
if (unconsolidated >= self.memory_window and session.key not in self._consolidating):
diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py
index ecb1440..f37ab1d 100644
--- a/nanobot/channels/telegram.py
+++ b/nanobot/channels/telegram.py
@@ -162,6 +162,7 @@ class TelegramChannel(BaseChannel):
BotCommand("new", "Start a new conversation"),
BotCommand("stop", "Stop the current task"),
BotCommand("help", "Show available commands"),
+ BotCommand("restart", "Restart the bot"),
]
def __init__(
@@ -223,6 +224,7 @@ class TelegramChannel(BaseChannel):
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("help", self._on_help))
# Add message handler for text, photos, voice, documents
From 711903bc5fd00be72009c0b04ab1e42d46239311 Mon Sep 17 00:00:00 2001
From: Zek
Date: Mon, 9 Mar 2026 17:54:02 +0800
Subject: [PATCH 04/36] 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 a660a25504b48170579a57496378e2fd843a556f Mon Sep 17 00:00:00 2001
From: chengyongru <2755839590@qq.com>
Date: Mon, 9 Mar 2026 22:00:45 +0800
Subject: [PATCH 05/36] feat(wecom): add wecom channel [wobsocket]
support text/audio[wecom support audio message by default]
---
nanobot/channels/manager.py | 14 +-
nanobot/channels/wecom.py | 352 ++++++++++++++++++++++++++++++++++++
nanobot/config/schema.py | 9 +
pyproject.toml | 1 +
4 files changed, 375 insertions(+), 1 deletion(-)
create mode 100644 nanobot/channels/wecom.py
diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py
index 51539dd..369795a 100644
--- a/nanobot/channels/manager.py
+++ b/nanobot/channels/manager.py
@@ -7,7 +7,6 @@ from typing import Any
from loguru import logger
-from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.schema import Config
@@ -150,6 +149,19 @@ class ChannelManager:
except ImportError as e:
logger.warning("Matrix channel not available: {}", e)
+ # WeCom channel
+ if self.config.channels.wecom.enabled:
+ try:
+ from nanobot.channels.wecom import WecomChannel
+ self.channels["wecom"] = WecomChannel(
+ self.config.channels.wecom,
+ self.bus,
+ groq_api_key=self.config.providers.groq.api_key,
+ )
+ logger.info("WeCom channel enabled")
+ except ImportError as e:
+ logger.warning("WeCom channel not available: {}", e)
+
self._validate_allow_from()
def _validate_allow_from(self) -> None:
diff --git a/nanobot/channels/wecom.py b/nanobot/channels/wecom.py
new file mode 100644
index 0000000..dc97311
--- /dev/null
+++ b/nanobot/channels/wecom.py
@@ -0,0 +1,352 @@
+"""WeCom (Enterprise WeChat) channel implementation using wecom_aibot_sdk."""
+
+import asyncio
+import importlib.util
+from collections import OrderedDict
+from typing import Any
+
+from loguru import logger
+
+from nanobot.bus.events import OutboundMessage
+from nanobot.bus.queue import MessageBus
+from nanobot.channels.base import BaseChannel
+from nanobot.config.paths import get_media_dir
+from nanobot.config.schema import WecomConfig
+
+WECOM_AVAILABLE = importlib.util.find_spec("wecom_aibot_sdk") is not None
+
+# Message type display mapping
+MSG_TYPE_MAP = {
+ "image": "[image]",
+ "voice": "[voice]",
+ "file": "[file]",
+ "mixed": "[mixed content]",
+}
+
+
+class WecomChannel(BaseChannel):
+ """
+ WeCom (Enterprise WeChat) channel using WebSocket long connection.
+
+ Uses WebSocket to receive events - no public IP or webhook required.
+
+ Requires:
+ - Bot ID and Secret from WeCom AI Bot platform
+ """
+
+ name = "wecom"
+
+ def __init__(self, config: WecomConfig, bus: MessageBus, groq_api_key: str = ""):
+ super().__init__(config, bus)
+ self.config: WecomConfig = config
+ self.groq_api_key = groq_api_key
+ self._client: Any = None
+ self._processed_message_ids: OrderedDict[str, None] = OrderedDict()
+ self._loop: asyncio.AbstractEventLoop | None = None
+ self._generate_req_id = None
+ # Store frame headers for each chat to enable replies
+ self._chat_frames: dict[str, Any] = {}
+
+ async def start(self) -> None:
+ """Start the WeCom bot with WebSocket long connection."""
+ if not WECOM_AVAILABLE:
+ logger.error("WeCom SDK not installed. Run: pip install wecom-aibot-sdk-python")
+ return
+
+ if not self.config.bot_id or not self.config.secret:
+ logger.error("WeCom bot_id and secret not configured")
+ return
+
+ from wecom_aibot_sdk import WSClient, generate_req_id
+
+ self._running = True
+ self._loop = asyncio.get_running_loop()
+ self._generate_req_id = generate_req_id
+
+ # Create WebSocket client
+ self._client = WSClient({
+ "bot_id": self.config.bot_id,
+ "secret": self.config.secret,
+ "reconnect_interval": 1000,
+ "max_reconnect_attempts": -1, # Infinite reconnect
+ "heartbeat_interval": 30000,
+ })
+
+ # Register event handlers
+ self._client.on("connected", self._on_connected)
+ self._client.on("authenticated", self._on_authenticated)
+ self._client.on("disconnected", self._on_disconnected)
+ self._client.on("error", self._on_error)
+ self._client.on("message.text", self._on_text_message)
+ self._client.on("message.image", self._on_image_message)
+ self._client.on("message.voice", self._on_voice_message)
+ self._client.on("message.file", self._on_file_message)
+ self._client.on("message.mixed", self._on_mixed_message)
+ self._client.on("event.enter_chat", self._on_enter_chat)
+
+ logger.info("WeCom bot starting with WebSocket long connection")
+ logger.info("No public IP required - using WebSocket to receive events")
+
+ # Connect
+ await self._client.connect_async()
+
+ # Keep running until stopped
+ while self._running:
+ await asyncio.sleep(1)
+
+ async def stop(self) -> None:
+ """Stop the WeCom bot."""
+ self._running = False
+ if self._client:
+ self._client.disconnect()
+ logger.info("WeCom bot stopped")
+
+ async def _on_connected(self, frame: Any) -> None:
+ """Handle WebSocket connected event."""
+ logger.info("WeCom WebSocket connected")
+
+ async def _on_authenticated(self, frame: Any) -> None:
+ """Handle authentication success event."""
+ logger.info("WeCom authenticated successfully")
+
+ async def _on_disconnected(self, frame: Any) -> None:
+ """Handle WebSocket disconnected event."""
+ reason = frame.body if hasattr(frame, 'body') else str(frame)
+ logger.warning("WeCom WebSocket disconnected: {}", reason)
+
+ async def _on_error(self, frame: Any) -> None:
+ """Handle error event."""
+ logger.error("WeCom error: {}", frame)
+
+ async def _on_text_message(self, frame: Any) -> None:
+ """Handle text message."""
+ await self._process_message(frame, "text")
+
+ async def _on_image_message(self, frame: Any) -> None:
+ """Handle image message."""
+ await self._process_message(frame, "image")
+
+ async def _on_voice_message(self, frame: Any) -> None:
+ """Handle voice message."""
+ await self._process_message(frame, "voice")
+
+ async def _on_file_message(self, frame: Any) -> None:
+ """Handle file message."""
+ await self._process_message(frame, "file")
+
+ async def _on_mixed_message(self, frame: Any) -> None:
+ """Handle mixed content message."""
+ await self._process_message(frame, "mixed")
+
+ async def _on_enter_chat(self, frame: Any) -> None:
+ """Handle enter_chat event (user opens chat with bot)."""
+ try:
+ # Extract body from WsFrame dataclass or dict
+ if hasattr(frame, 'body'):
+ body = frame.body or {}
+ elif isinstance(frame, dict):
+ body = frame.get("body", frame)
+ else:
+ body = {}
+
+ chat_id = body.get("chatid", "") if isinstance(body, dict) else ""
+
+ if chat_id and self.config.welcome_message:
+ await self._client.reply_welcome(frame, {
+ "msgtype": "text",
+ "text": {"content": self.config.welcome_message},
+ })
+ except Exception as e:
+ logger.error("Error handling enter_chat: {}", e)
+
+ async def _process_message(self, frame: Any, msg_type: str) -> None:
+ """Process incoming message and forward to bus."""
+ try:
+ # Extract body from WsFrame dataclass or dict
+ if hasattr(frame, 'body'):
+ body = frame.body or {}
+ elif isinstance(frame, dict):
+ body = frame.get("body", frame)
+ else:
+ body = {}
+
+ # Ensure body is a dict
+ if not isinstance(body, dict):
+ logger.warning("Invalid body type: {}", type(body))
+ return
+
+ # Extract message info
+ msg_id = body.get("msgid", "")
+ if not msg_id:
+ msg_id = f"{body.get('chatid', '')}_{body.get('sendertime', '')}"
+
+ # Deduplication check
+ if msg_id in self._processed_message_ids:
+ return
+ self._processed_message_ids[msg_id] = None
+
+ # Trim cache
+ while len(self._processed_message_ids) > 1000:
+ self._processed_message_ids.popitem(last=False)
+
+ # Extract sender info from "from" field (SDK format)
+ from_info = body.get("from", {})
+ sender_id = from_info.get("userid", "unknown") if isinstance(from_info, dict) else "unknown"
+
+ # For single chat, chatid is the sender's userid
+ # For group chat, chatid is provided in body
+ chat_type = body.get("chattype", "single")
+ chat_id = body.get("chatid", sender_id)
+
+ content_parts = []
+
+ if msg_type == "text":
+ text = body.get("text", {}).get("content", "")
+ if text:
+ content_parts.append(text)
+
+ elif msg_type == "image":
+ image_info = body.get("image", {})
+ file_url = image_info.get("url", "")
+ aes_key = image_info.get("aeskey", "")
+
+ if file_url and aes_key:
+ file_path = await self._download_and_save_media(file_url, aes_key, "image")
+ if file_path:
+ import os
+ filename = os.path.basename(file_path)
+ content_parts.append(f"[image: {filename}]\n[Image: source: {file_path}]")
+ else:
+ content_parts.append("[image: download failed]")
+ else:
+ content_parts.append("[image: download failed]")
+
+ elif msg_type == "voice":
+ voice_info = body.get("voice", {})
+ # Voice message already contains transcribed content from WeCom
+ voice_content = voice_info.get("content", "")
+ if voice_content:
+ content_parts.append(f"[voice] {voice_content}")
+ else:
+ content_parts.append("[voice]")
+
+ elif msg_type == "file":
+ file_info = body.get("file", {})
+ file_url = file_info.get("url", "")
+ aes_key = file_info.get("aeskey", "")
+ file_name = file_info.get("name", "unknown")
+
+ if file_url and aes_key:
+ file_path = await self._download_and_save_media(file_url, aes_key, "file", file_name)
+ if file_path:
+ content_parts.append(f"[file: {file_name}]\n[File: source: {file_path}]")
+ else:
+ content_parts.append(f"[file: {file_name}: download failed]")
+ else:
+ content_parts.append(f"[file: {file_name}: download failed]")
+
+ elif msg_type == "mixed":
+ # Mixed content contains multiple message items
+ msg_items = body.get("mixed", {}).get("item", [])
+ for item in msg_items:
+ item_type = item.get("type", "")
+ if item_type == "text":
+ text = item.get("text", {}).get("content", "")
+ if text:
+ content_parts.append(text)
+ else:
+ content_parts.append(MSG_TYPE_MAP.get(item_type, f"[{item_type}]"))
+
+ else:
+ content_parts.append(MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]"))
+
+ content = "\n".join(content_parts) if content_parts else ""
+
+ if not content:
+ return
+
+ # Store frame for this chat to enable replies
+ self._chat_frames[chat_id] = frame
+
+ # Forward to message bus
+ # Note: media paths are included in content for broader model compatibility
+ await self._handle_message(
+ sender_id=sender_id,
+ chat_id=chat_id,
+ content=content,
+ media=None,
+ metadata={
+ "message_id": msg_id,
+ "msg_type": msg_type,
+ "chat_type": chat_type,
+ }
+ )
+
+ except Exception as e:
+ logger.error("Error processing WeCom message: {}", e)
+
+ async def _download_and_save_media(
+ self,
+ file_url: str,
+ aes_key: str,
+ media_type: str,
+ filename: str | None = None,
+ ) -> str | None:
+ """
+ Download and decrypt media from WeCom.
+
+ Returns:
+ file_path or None if download failed
+ """
+ try:
+ data, fname = await self._client.download_file(file_url, aes_key)
+
+ if not data:
+ logger.warning("Failed to download media from WeCom")
+ return None
+
+ media_dir = get_media_dir("wecom")
+ if not filename:
+ filename = fname or f"{media_type}_{hash(file_url) % 100000}"
+
+ file_path = media_dir / filename
+ file_path.write_bytes(data)
+ logger.debug("Downloaded {} to {}", media_type, file_path)
+ return str(file_path)
+
+ except Exception as e:
+ logger.error("Error downloading media: {}", e)
+ return None
+
+ async def send(self, msg: OutboundMessage) -> None:
+ """Send a message through WeCom."""
+ if not self._client:
+ logger.warning("WeCom client not initialized")
+ return
+
+ try:
+ content = msg.content.strip()
+ if not content:
+ return
+
+ # Get the stored frame for this chat
+ frame = self._chat_frames.get(msg.chat_id)
+ if not frame:
+ logger.warning("No frame found for chat {}, cannot reply", msg.chat_id)
+ return
+
+ # Use streaming reply for better UX
+ stream_id = self._generate_req_id("stream")
+
+ # Send as streaming message with finish=True
+ await self._client.reply_stream(
+ frame,
+ stream_id,
+ content,
+ finish=True,
+ )
+
+ logger.debug("WeCom message sent to {}", msg.chat_id)
+
+ except Exception as e:
+ logger.error("Error sending WeCom message: {}", e)
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 803cb61..63eae48 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -199,7 +199,15 @@ class QQConfig(Base):
) # Allowed user openids (empty = public access)
+class WecomConfig(Base):
+ """WeCom (Enterprise WeChat) AI Bot channel configuration."""
+ enabled: bool = False
+ bot_id: str = "" # Bot ID from WeCom AI Bot platform
+ secret: str = "" # Bot Secret from WeCom AI Bot platform
+ allow_from: list[str] = Field(default_factory=list) # Allowed user IDs
+ welcome_message: str = "" # Welcome message for enter_chat event
+ react_emoji: str = "eyes" # Emoji for message reactions
class ChannelsConfig(Base):
"""Configuration for chat channels."""
@@ -216,6 +224,7 @@ class ChannelsConfig(Base):
slack: SlackConfig = Field(default_factory=SlackConfig)
qq: QQConfig = Field(default_factory=QQConfig)
matrix: MatrixConfig = Field(default_factory=MatrixConfig)
+ wecom: WecomConfig = Field(default_factory=WecomConfig)
class AgentDefaults(Base):
diff --git a/pyproject.toml b/pyproject.toml
index 62cf616..fac53ce 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -44,6 +44,7 @@ dependencies = [
"json-repair>=0.57.0,<1.0.0",
"chardet>=3.0.2,<6.0.0",
"openai>=2.8.0",
+ "wecom-aibot-sdk-python>=0.1.2",
]
[project.optional-dependencies]
From 45c0eebae5a700cfa5da28c2ff31208f34180509 Mon Sep 17 00:00:00 2001
From: chengyongru <2755839590@qq.com>
Date: Tue, 10 Mar 2026 00:53:23 +0800
Subject: [PATCH 06/36] docs(wecom): add wecom configuration guide in readme
---
README.md | 39 +++++++++++++++++++++++++++++++++++++++
1 file changed, 39 insertions(+)
diff --git a/README.md b/README.md
index d3401ea..3d5fb63 100644
--- a/README.md
+++ b/README.md
@@ -207,6 +207,7 @@ Connect nanobot to your favorite chat platform.
| **Slack** | Bot token + App-Level token |
| **Email** | IMAP/SMTP credentials |
| **QQ** | App ID + App Secret |
+| **Wecom** | Bot ID + App Secret |
Telegram (Recommended)
@@ -676,6 +677,44 @@ nanobot gateway
+
+Wecom (企业微信)
+
+Uses **WebSocket** long connection — no public IP required.
+
+**1. Create a wecom bot**
+
+In the client's workspace, click on "Intelligent Robot" to create a robot and choose API mode for creation.
+Select to create in "long connection" mode, and obtain Bot ID and Secret.
+
+**2. Configure**
+
+```json
+{
+ "channels": {
+ "wecom": {
+ "enabled": true,
+ "botId": "your_bot_id",
+ "secret": "your_secret",
+ "allowFrom": [
+ "your_id"
+ ]
+ }
+ }
+}
+```
+
+**3. Run**
+
+```bash
+nanobot gateway
+```
+
+> [!TIP]
+> wecom uses WebSocket to receive messages — no webhook or public IP needed!
+
+
+
## 🌐 Agent Social Network
🐈 nanobot is capable of linking to the agent social network (agent community). **Just send one message and your nanobot joins automatically!**
From 6c70154feeeff638cfb79a6e19d263f36ea7f5f6 Mon Sep 17 00:00:00 2001
From: suger-m
Date: Tue, 10 Mar 2026 15:55:04 +0800
Subject: [PATCH 07/36] fix(exec): enforce workspace guard for home-expanded
paths
---
nanobot/agent/tools/shell.py | 6 ++++--
tests/test_tool_validation.py | 13 +++++++++++++
2 files changed, 17 insertions(+), 2 deletions(-)
diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py
index ce19920..4726e3c 100644
--- a/nanobot/agent/tools/shell.py
+++ b/nanobot/agent/tools/shell.py
@@ -143,7 +143,8 @@ class ExecTool(Tool):
for raw in self._extract_absolute_paths(cmd):
try:
- p = Path(raw.strip()).resolve()
+ expanded = os.path.expandvars(raw.strip())
+ p = Path(expanded).expanduser().resolve()
except Exception:
continue
if p.is_absolute() and cwd_path not in p.parents and p != cwd_path:
@@ -155,4 +156,5 @@ class ExecTool(Tool):
def _extract_absolute_paths(command: str) -> list[str]:
win_paths = re.findall(r"[A-Za-z]:\\[^\s\"'|><;]+", command) # Windows: C:\...
posix_paths = re.findall(r"(?:^|[\s|>])(/[^\s\"'>]+)", command) # POSIX: /absolute only
- return win_paths + posix_paths
+ home_paths = re.findall(r"(?:^|[\s|>])(~[^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~
+ return win_paths + posix_paths + home_paths
diff --git a/tests/test_tool_validation.py b/tests/test_tool_validation.py
index c2b4b6a..cf648bf 100644
--- a/tests/test_tool_validation.py
+++ b/tests/test_tool_validation.py
@@ -108,6 +108,19 @@ def test_exec_extract_absolute_paths_captures_posix_absolute_paths() -> None:
assert "/tmp/out.txt" in paths
+def test_exec_extract_absolute_paths_captures_home_paths() -> None:
+ cmd = "cat ~/.nanobot/config.json > ~/out.txt"
+ paths = ExecTool._extract_absolute_paths(cmd)
+ assert "~/.nanobot/config.json" in paths
+ assert "~/out.txt" in paths
+
+
+def test_exec_guard_blocks_home_path_outside_workspace(tmp_path) -> None:
+ tool = ExecTool(restrict_to_workspace=True)
+ error = tool._guard_command("cat ~/.nanobot/config.json", str(tmp_path))
+ assert error == "Error: Command blocked by safety guard (path outside working dir)"
+
+
# --- cast_params tests ---
From 808064e26bf03ad1b645b76af2181d3356d35e47 Mon Sep 17 00:00:00 2001
From: Nikolas de Hor
Date: Tue, 10 Mar 2026 13:45:05 -0300
Subject: [PATCH 08/36] fix: detect tilde paths in restrictToWorkspace shell
guard
_extract_absolute_paths() only matched paths starting with / or drive
letters, missing ~ paths that expand to the home directory. This
allowed agents to bypass restrictToWorkspace by using commands like
cat ~/.nanobot/config.json to access files outside the workspace.
Add tilde path extraction regex and use expanduser() before resolving.
Also switch from manual parent-chain check to is_relative_to() for
more robust path containment validation.
Fixes #1817
---
nanobot/agent/tools/shell.py | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py
index ce19920..b4a4044 100644
--- a/nanobot/agent/tools/shell.py
+++ b/nanobot/agent/tools/shell.py
@@ -143,10 +143,10 @@ class ExecTool(Tool):
for raw in self._extract_absolute_paths(cmd):
try:
- p = Path(raw.strip()).resolve()
+ p = Path(raw.strip()).expanduser().resolve()
except Exception:
continue
- if p.is_absolute() and cwd_path not in p.parents and p != cwd_path:
+ if not p.is_relative_to(cwd_path):
return "Error: Command blocked by safety guard (path outside working dir)"
return None
@@ -155,4 +155,5 @@ class ExecTool(Tool):
def _extract_absolute_paths(command: str) -> list[str]:
win_paths = re.findall(r"[A-Za-z]:\\[^\s\"'|><;]+", command) # Windows: C:\...
posix_paths = re.findall(r"(?:^|[\s|>])(/[^\s\"'>]+)", command) # POSIX: /absolute only
- return win_paths + posix_paths
+ tilde_paths = re.findall(r"(?:^|[\s|>])(~[^\s\"'>]*)", command) # Tilde: ~/...
+ return win_paths + posix_paths + tilde_paths
From dee4f27dce4a8837eea4b97b882314c50a2b74e3 Mon Sep 17 00:00:00 2001
From: "Jerome Sonnet (letzdoo)"
Date: Wed, 11 Mar 2026 07:43:28 +0400
Subject: [PATCH 09/36] feat: add Ollama as a local LLM provider
Add native Ollama support so local models (e.g. nemotron-3-nano) can be
used without an API key. Adds ProviderSpec with ollama_chat LiteLLM
prefix, ProvidersConfig field, and skips API key validation for local
providers.
Co-Authored-By: Claude Opus 4.6
---
nanobot/cli/commands.py | 2 +-
nanobot/config/schema.py | 5 +++--
nanobot/providers/registry.py | 17 +++++++++++++++++
3 files changed, 21 insertions(+), 3 deletions(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index cf69450..8387b28 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -252,7 +252,7 @@ def _make_provider(config: Config):
from nanobot.providers.litellm_provider import LiteLLMProvider
from nanobot.providers.registry import find_by_name
spec = find_by_name(provider_name)
- if not model.startswith("bedrock/") and not (p and p.api_key) and not (spec and spec.is_oauth):
+ if not model.startswith("bedrock/") and not (p and p.api_key) and not (spec and (spec.is_oauth or spec.is_local)):
console.print("[red]Error: No API key configured.[/red]")
console.print("Set one in ~/.nanobot/config.json under providers section")
raise typer.Exit(1)
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index a2de239..9b5821b 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -272,6 +272,7 @@ class ProvidersConfig(Base):
moonshot: ProviderConfig = Field(default_factory=ProviderConfig)
minimax: ProviderConfig = Field(default_factory=ProviderConfig)
aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway
+ ollama: ProviderConfig = Field(default_factory=ProviderConfig) # Ollama local models
siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动)
volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎)
openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth)
@@ -375,14 +376,14 @@ class Config(BaseSettings):
for spec in PROVIDERS:
p = getattr(self.providers, spec.name, None)
if p and model_prefix and normalized_prefix == spec.name:
- if spec.is_oauth or p.api_key:
+ if spec.is_oauth or spec.is_local or p.api_key:
return p, spec.name
# Match by keyword (order follows PROVIDERS registry)
for spec in PROVIDERS:
p = getattr(self.providers, spec.name, None)
if p and any(_kw_matches(kw) for kw in spec.keywords):
- if spec.is_oauth or p.api_key:
+ if spec.is_oauth or spec.is_local or p.api_key:
return p, spec.name
# Fallback: gateways first, then others (follows registry order)
diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py
index 3ba1a0e..c4bcfe2 100644
--- a/nanobot/providers/registry.py
+++ b/nanobot/providers/registry.py
@@ -360,6 +360,23 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
strip_model_prefix=False,
model_overrides=(),
),
+ # === Ollama (local, OpenAI-compatible) ===================================
+ ProviderSpec(
+ name="ollama",
+ keywords=("ollama", "nemotron"),
+ env_key="OLLAMA_API_KEY",
+ display_name="Ollama",
+ litellm_prefix="ollama_chat", # model → ollama_chat/model
+ skip_prefixes=("ollama/", "ollama_chat/"),
+ env_extras=(),
+ is_gateway=False,
+ is_local=True,
+ detect_by_key_prefix="",
+ detect_by_base_keyword="11434",
+ default_api_base="http://localhost:11434",
+ strip_model_prefix=False,
+ model_overrides=(),
+ ),
# === Auxiliary (not a primary LLM provider) ============================
# Groq: mainly used for Whisper voice transcription, also usable for LLM.
# Needs "groq/" prefix for LiteLLM routing. Placed last — it rarely wins fallback.
From c7e2622ee1cb313ca3f7a4a31779813cc3ebc27b Mon Sep 17 00:00:00 2001
From: ethanclaw
Date: Wed, 11 Mar 2026 12:25:28 +0800
Subject: [PATCH 10/36] fix(subagent): pass reasoning_content and
thinking_blocks in subagent messages
Fix issue #1834: Spawn/subagent tool fails with Deepseek Reasoner
due to missing reasoning_content field when using thinking mode.
The subagent was not including reasoning_content and thinking_blocks
in assistant messages with tool calls, causing the Deepseek API to
reject subsequent requests.
- Add reasoning_content to assistant message when subagent makes tool calls
- Add thinking_blocks to assistant message for Anthropic extended thinking
- Add tests to verify both fields are properly passed
Fixes #1834
---
nanobot/agent/subagent.py | 2 +
tests/test_subagent_reasoning.py | 144 +++++++++++++++++++++++++++++++
2 files changed, 146 insertions(+)
create mode 100644 tests/test_subagent_reasoning.py
diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py
index f9eda1f..6163a52 100644
--- a/nanobot/agent/subagent.py
+++ b/nanobot/agent/subagent.py
@@ -149,6 +149,8 @@ class SubagentManager:
"role": "assistant",
"content": response.content or "",
"tool_calls": tool_call_dicts,
+ "reasoning_content": response.reasoning_content,
+ "thinking_blocks": response.thinking_blocks,
})
# Execute tools
diff --git a/tests/test_subagent_reasoning.py b/tests/test_subagent_reasoning.py
new file mode 100644
index 0000000..5e70506
--- /dev/null
+++ b/tests/test_subagent_reasoning.py
@@ -0,0 +1,144 @@
+"""Tests for subagent reasoning_content and thinking_blocks handling."""
+
+from __future__ import annotations
+
+import asyncio
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+
+class TestSubagentReasoningContent:
+ """Test that subagent properly handles reasoning_content and thinking_blocks."""
+
+ @pytest.mark.asyncio
+ async def test_subagent_message_includes_reasoning_content(self):
+ """Verify reasoning_content is included in assistant messages with tool calls.
+
+ This is the fix for issue #1834: Spawn/subagent tool fails with
+ Deepseek Reasoner due to missing reasoning_content field.
+ """
+ from nanobot.agent.subagent import SubagentManager
+ from nanobot.bus.queue import MessageBus
+ from nanobot.providers.base import LLMResponse, ToolCallRequest
+
+ bus = MessageBus()
+ provider = MagicMock()
+ provider.get_default_model.return_value = "deepseek-reasoner"
+
+ # Create a real Path object for workspace
+ workspace = Path("/tmp/test_workspace")
+ workspace.mkdir(parents=True, exist_ok=True)
+
+ # Capture messages that are sent to the provider
+ captured_messages = []
+
+ async def mock_chat(*args, **kwargs):
+ captured_messages.append(kwargs.get("messages", []))
+ # Return response with tool calls and reasoning_content
+ tool_call = ToolCallRequest(
+ id="test-1",
+ name="read_file",
+ arguments={"path": "/test.txt"},
+ )
+ return LLMResponse(
+ content="",
+ tool_calls=[tool_call],
+ reasoning_content="I need to read this file first",
+ )
+
+ provider.chat_with_retry = AsyncMock(side_effect=mock_chat)
+
+ mgr = SubagentManager(provider=provider, workspace=workspace, bus=bus)
+
+ # Mock the tools registry
+ with patch("nanobot.agent.subagent.ToolRegistry") as MockToolRegistry:
+ mock_registry = MagicMock()
+ mock_registry.get_definitions.return_value = []
+ mock_registry.execute = AsyncMock(return_value="file content")
+ MockToolRegistry.return_value = mock_registry
+
+ result = await mgr.spawn(
+ task="Read a file",
+ label="test",
+ origin_channel="cli",
+ origin_chat_id="direct",
+ session_key="cli:direct",
+ )
+
+ # Wait for the task to complete
+ await asyncio.sleep(0.5)
+
+ # Check the captured messages
+ assert len(captured_messages) >= 1
+ # Find the assistant message with tool_calls
+ found = False
+ for msg_list in captured_messages:
+ for msg in msg_list:
+ if msg.get("role") == "assistant" and msg.get("tool_calls"):
+ assert "reasoning_content" in msg, "reasoning_content should be in assistant message with tool_calls"
+ assert msg["reasoning_content"] == "I need to read this file first"
+ found = True
+ assert found, "Should have found an assistant message with tool_calls"
+
+ @pytest.mark.asyncio
+ async def test_subagent_message_includes_thinking_blocks(self):
+ """Verify thinking_blocks is included in assistant messages with tool calls."""
+ from nanobot.agent.subagent import SubagentManager
+ from nanobot.bus.queue import MessageBus
+ from nanobot.providers.base import LLMResponse, ToolCallRequest
+
+ bus = MessageBus()
+ provider = MagicMock()
+ provider.get_default_model.return_value = "claude-sonnet"
+
+ workspace = Path("/tmp/test_workspace2")
+ workspace.mkdir(parents=True, exist_ok=True)
+
+ captured_messages = []
+
+ async def mock_chat(*args, **kwargs):
+ captured_messages.append(kwargs.get("messages", []))
+ tool_call = ToolCallRequest(
+ id="test-2",
+ name="read_file",
+ arguments={"path": "/test.txt"},
+ )
+ return LLMResponse(
+ content="",
+ tool_calls=[tool_call],
+ thinking_blocks=[
+ {"signature": "sig1", "thought": "thinking step 1"},
+ {"signature": "sig2", "thought": "thinking step 2"},
+ ],
+ )
+
+ provider.chat_with_retry = AsyncMock(side_effect=mock_chat)
+
+ mgr = SubagentManager(provider=provider, workspace=workspace, bus=bus)
+
+ with patch("nanobot.agent.subagent.ToolRegistry") as MockToolRegistry:
+ mock_registry = MagicMock()
+ mock_registry.get_definitions.return_value = []
+ mock_registry.execute = AsyncMock(return_value="file content")
+ MockToolRegistry.return_value = mock_registry
+
+ result = await mgr.spawn(
+ task="Read a file",
+ label="test",
+ origin_channel="cli",
+ origin_chat_id="direct",
+ )
+
+ await asyncio.sleep(0.5)
+
+ # Check the captured messages
+ found = False
+ for msg_list in captured_messages:
+ for msg in msg_list:
+ if msg.get("role") == "assistant" and msg.get("tool_calls"):
+ assert "thinking_blocks" in msg, "thinking_blocks should be in assistant message with tool_calls"
+ assert len(msg["thinking_blocks"]) == 2
+ found = True
+ assert found, "Should have found an assistant message with tool_calls"
From 12104c8d46c0b688e0db21617b23d54f012970ba Mon Sep 17 00:00:00 2001
From: ethanclaw
Date: Wed, 11 Mar 2026 14:22:33 +0800
Subject: [PATCH 11/36] fix(memory): pass temperature, max_tokens and
reasoning_effort to memory consolidation
Fix issue #1823: Memory consolidation does not inherit agent temperature
and maxTokens configuration.
The agent's configured generation parameters were not being passed through
to the memory consolidation call, causing it to fall back to default values.
This resulted in the consolidation response being truncated before the
save_memory tool call was emitted.
- Pass temperature, max_tokens, reasoning_effort from AgentLoop to
MemoryConsolidator and then to MemoryStore.consolidate()
- Forward these parameters to the provider.chat_with_retry() call
Fixes #1823
---
nanobot/agent/loop.py | 3 +++
nanobot/agent/memory.py | 21 ++++++++++++++++++++-
2 files changed, 23 insertions(+), 1 deletion(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 8605a09..edf1e8e 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -114,6 +114,9 @@ class AgentLoop:
context_window_tokens=context_window_tokens,
build_messages=self.context.build_messages,
get_tool_definitions=self.tools.get_definitions,
+ temperature=self.temperature,
+ max_tokens=self.max_tokens,
+ reasoning_effort=self.reasoning_effort,
)
self._register_default_tools()
diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py
index cd5f54f..d79887b 100644
--- a/nanobot/agent/memory.py
+++ b/nanobot/agent/memory.py
@@ -99,6 +99,9 @@ class MemoryStore:
messages: list[dict],
provider: LLMProvider,
model: str,
+ temperature: float | None = None,
+ max_tokens: int | None = None,
+ reasoning_effort: str | None = None,
) -> bool:
"""Consolidate the provided message chunk into MEMORY.md + HISTORY.md."""
if not messages:
@@ -121,6 +124,9 @@ class MemoryStore:
],
tools=_SAVE_MEMORY_TOOL,
model=model,
+ temperature=temperature,
+ max_tokens=max_tokens,
+ reasoning_effort=reasoning_effort,
)
if not response.has_tool_calls:
@@ -160,6 +166,9 @@ class MemoryConsolidator:
context_window_tokens: int,
build_messages: Callable[..., list[dict[str, Any]]],
get_tool_definitions: Callable[[], list[dict[str, Any]]],
+ temperature: float | None = None,
+ max_tokens: int | None = None,
+ reasoning_effort: str | None = None,
):
self.store = MemoryStore(workspace)
self.provider = provider
@@ -168,6 +177,9 @@ class MemoryConsolidator:
self.context_window_tokens = context_window_tokens
self._build_messages = build_messages
self._get_tool_definitions = get_tool_definitions
+ self._temperature = temperature
+ self._max_tokens = max_tokens
+ self._reasoning_effort = reasoning_effort
self._locks: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary()
def get_lock(self, session_key: str) -> asyncio.Lock:
@@ -176,7 +188,14 @@ class MemoryConsolidator:
async def consolidate_messages(self, messages: list[dict[str, object]]) -> bool:
"""Archive a selected message chunk into persistent memory."""
- return await self.store.consolidate(messages, self.provider, self.model)
+ return await self.store.consolidate(
+ messages,
+ self.provider,
+ self.model,
+ temperature=self._temperature,
+ max_tokens=self._max_tokens,
+ reasoning_effort=self._reasoning_effort,
+ )
def pick_consolidation_boundary(
self,
From ed82f95f0ca23605d896ff1785dd93dbb4ab70c4 Mon Sep 17 00:00:00 2001
From: WhalerO
Date: Wed, 11 Mar 2026 09:56:18 +0800
Subject: [PATCH 12/36] fix: preserve provider-specific tool call metadata for
Gemini
---
nanobot/agent/loop.py | 25 ++++++++----
nanobot/agent/subagent.py | 25 ++++++++----
nanobot/providers/base.py | 2 +
nanobot/providers/litellm_provider.py | 7 ++++
tests/test_gemini_thought_signature.py | 54 ++++++++++++++++++++++++++
5 files changed, 97 insertions(+), 16 deletions(-)
create mode 100644 tests/test_gemini_thought_signature.py
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index fcbc880..147327d 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -208,14 +208,7 @@ class AgentLoop:
await on_progress(self._tool_hint(response.tool_calls), tool_hint=True)
tool_call_dicts = [
- {
- "id": tc.id,
- "type": "function",
- "function": {
- "name": tc.name,
- "arguments": json.dumps(tc.arguments, ensure_ascii=False)
- }
- }
+ self._build_tool_call_message(tc)
for tc in response.tool_calls
]
messages = self.context.add_assistant_message(
@@ -256,6 +249,22 @@ class AgentLoop:
return final_content, tools_used, messages
+ @staticmethod
+ def _build_tool_call_message(tc: Any) -> dict[str, Any]:
+ tool_call = {
+ "id": tc.id,
+ "type": "function",
+ "function": {
+ "name": tc.name,
+ "arguments": json.dumps(tc.arguments, ensure_ascii=False)
+ }
+ }
+ if getattr(tc, "provider_specific_fields", None):
+ tool_call["provider_specific_fields"] = tc.provider_specific_fields
+ if getattr(tc, "function_provider_specific_fields", None):
+ tool_call["function"]["provider_specific_fields"] = tc.function_provider_specific_fields
+ return tool_call
+
async def run(self) -> None:
"""Run the agent loop, dispatching messages as tasks to stay responsive to /stop."""
self._running = True
diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py
index f9eda1f..5f98272 100644
--- a/nanobot/agent/subagent.py
+++ b/nanobot/agent/subagent.py
@@ -135,14 +135,7 @@ class SubagentManager:
if response.has_tool_calls:
# Add assistant message with tool calls
tool_call_dicts = [
- {
- "id": tc.id,
- "type": "function",
- "function": {
- "name": tc.name,
- "arguments": json.dumps(tc.arguments, ensure_ascii=False),
- },
- }
+ self._build_tool_call_message(tc)
for tc in response.tool_calls
]
messages.append({
@@ -230,6 +223,22 @@ Stay focused on the assigned task. Your final response will be reported back to
parts.append(f"## Skills\n\nRead SKILL.md with read_file to use a skill.\n\n{skills_summary}")
return "\n\n".join(parts)
+
+ @staticmethod
+ def _build_tool_call_message(tc: Any) -> dict[str, Any]:
+ tool_call = {
+ "id": tc.id,
+ "type": "function",
+ "function": {
+ "name": tc.name,
+ "arguments": json.dumps(tc.arguments, ensure_ascii=False),
+ },
+ }
+ if getattr(tc, "provider_specific_fields", None):
+ tool_call["provider_specific_fields"] = tc.provider_specific_fields
+ if getattr(tc, "function_provider_specific_fields", None):
+ tool_call["function"]["provider_specific_fields"] = tc.function_provider_specific_fields
+ return tool_call
async def cancel_by_session(self, session_key: str) -> int:
"""Cancel all subagents for the given session. Returns count cancelled."""
diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py
index a3b6c47..b41ce28 100644
--- a/nanobot/providers/base.py
+++ b/nanobot/providers/base.py
@@ -14,6 +14,8 @@ class ToolCallRequest:
id: str
name: str
arguments: dict[str, Any]
+ provider_specific_fields: dict[str, Any] | None = None
+ function_provider_specific_fields: dict[str, Any] | None = None
@dataclass
diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py
index cb67635..af91c2f 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -309,10 +309,17 @@ class LiteLLMProvider(LLMProvider):
if isinstance(args, str):
args = json_repair.loads(args)
+ provider_specific_fields = getattr(tc, "provider_specific_fields", None) or None
+ function_provider_specific_fields = (
+ getattr(tc.function, "provider_specific_fields", None) or None
+ )
+
tool_calls.append(ToolCallRequest(
id=_short_tool_id(),
name=tc.function.name,
arguments=args,
+ provider_specific_fields=provider_specific_fields,
+ function_provider_specific_fields=function_provider_specific_fields,
))
usage = {}
diff --git a/tests/test_gemini_thought_signature.py b/tests/test_gemini_thought_signature.py
new file mode 100644
index 0000000..db57c7f
--- /dev/null
+++ b/tests/test_gemini_thought_signature.py
@@ -0,0 +1,54 @@
+from types import SimpleNamespace
+
+from nanobot.agent.loop import AgentLoop
+from nanobot.providers.base import ToolCallRequest
+from nanobot.providers.litellm_provider import LiteLLMProvider
+
+
+def test_litellm_parse_response_preserves_tool_call_provider_fields() -> None:
+ provider = LiteLLMProvider(default_model="gemini/gemini-3-flash")
+
+ response = SimpleNamespace(
+ choices=[
+ SimpleNamespace(
+ finish_reason="tool_calls",
+ message=SimpleNamespace(
+ content=None,
+ tool_calls=[
+ SimpleNamespace(
+ id="call_123",
+ function=SimpleNamespace(
+ name="read_file",
+ arguments='{"path":"todo.md"}',
+ provider_specific_fields={"inner": "value"},
+ ),
+ provider_specific_fields={"thought_signature": "signed-token"},
+ )
+ ],
+ ),
+ )
+ ],
+ usage=None,
+ )
+
+ parsed = provider._parse_response(response)
+
+ assert len(parsed.tool_calls) == 1
+ assert parsed.tool_calls[0].provider_specific_fields == {"thought_signature": "signed-token"}
+ assert parsed.tool_calls[0].function_provider_specific_fields == {"inner": "value"}
+
+
+def test_agent_loop_replays_tool_call_provider_fields() -> None:
+ tool_call = ToolCallRequest(
+ id="abc123xyz",
+ name="read_file",
+ arguments={"path": "todo.md"},
+ provider_specific_fields={"thought_signature": "signed-token"},
+ function_provider_specific_fields={"inner": "value"},
+ )
+
+ message = AgentLoop._build_tool_call_message(tool_call)
+
+ assert message["provider_specific_fields"] == {"thought_signature": "signed-token"}
+ assert message["function"]["provider_specific_fields"] == {"inner": "value"}
+ assert message["function"]["arguments"] == '{"path": "todo.md"}'
From 6ef7ab53d089f9b9d25651e37ab0d8c4a3c607a1 Mon Sep 17 00:00:00 2001
From: WhalerO
Date: Wed, 11 Mar 2026 15:01:18 +0800
Subject: [PATCH 13/36] refactor: centralize tool call serialization in
ToolCallRequest
---
nanobot/agent/loop.py | 18 +-----------------
nanobot/agent/subagent.py | 18 +-----------------
nanobot/providers/base.py | 17 +++++++++++++++++
tests/test_gemini_thought_signature.py | 5 ++---
4 files changed, 21 insertions(+), 37 deletions(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 147327d..8949844 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -208,7 +208,7 @@ class AgentLoop:
await on_progress(self._tool_hint(response.tool_calls), tool_hint=True)
tool_call_dicts = [
- self._build_tool_call_message(tc)
+ tc.to_openai_tool_call()
for tc in response.tool_calls
]
messages = self.context.add_assistant_message(
@@ -249,22 +249,6 @@ class AgentLoop:
return final_content, tools_used, messages
- @staticmethod
- def _build_tool_call_message(tc: Any) -> dict[str, Any]:
- tool_call = {
- "id": tc.id,
- "type": "function",
- "function": {
- "name": tc.name,
- "arguments": json.dumps(tc.arguments, ensure_ascii=False)
- }
- }
- if getattr(tc, "provider_specific_fields", None):
- tool_call["provider_specific_fields"] = tc.provider_specific_fields
- if getattr(tc, "function_provider_specific_fields", None):
- tool_call["function"]["provider_specific_fields"] = tc.function_provider_specific_fields
- return tool_call
-
async def run(self) -> None:
"""Run the agent loop, dispatching messages as tasks to stay responsive to /stop."""
self._running = True
diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py
index 5f98272..0049f9a 100644
--- a/nanobot/agent/subagent.py
+++ b/nanobot/agent/subagent.py
@@ -135,7 +135,7 @@ class SubagentManager:
if response.has_tool_calls:
# Add assistant message with tool calls
tool_call_dicts = [
- self._build_tool_call_message(tc)
+ tc.to_openai_tool_call()
for tc in response.tool_calls
]
messages.append({
@@ -224,22 +224,6 @@ Stay focused on the assigned task. Your final response will be reported back to
return "\n\n".join(parts)
- @staticmethod
- def _build_tool_call_message(tc: Any) -> dict[str, Any]:
- tool_call = {
- "id": tc.id,
- "type": "function",
- "function": {
- "name": tc.name,
- "arguments": json.dumps(tc.arguments, ensure_ascii=False),
- },
- }
- if getattr(tc, "provider_specific_fields", None):
- tool_call["provider_specific_fields"] = tc.provider_specific_fields
- if getattr(tc, "function_provider_specific_fields", None):
- tool_call["function"]["provider_specific_fields"] = tc.function_provider_specific_fields
- return tool_call
-
async def cancel_by_session(self, session_key: str) -> int:
"""Cancel all subagents for the given session. Returns count cancelled."""
tasks = [self._running_tasks[tid] for tid in self._session_tasks.get(session_key, [])
diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py
index b41ce28..391f903 100644
--- a/nanobot/providers/base.py
+++ b/nanobot/providers/base.py
@@ -1,6 +1,7 @@
"""Base LLM provider interface."""
import asyncio
+import json
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any
@@ -17,6 +18,22 @@ class ToolCallRequest:
provider_specific_fields: dict[str, Any] | None = None
function_provider_specific_fields: dict[str, Any] | None = None
+ def to_openai_tool_call(self) -> dict[str, Any]:
+ """Serialize to an OpenAI-style tool_call payload."""
+ tool_call = {
+ "id": self.id,
+ "type": "function",
+ "function": {
+ "name": self.name,
+ "arguments": json.dumps(self.arguments, ensure_ascii=False),
+ },
+ }
+ if self.provider_specific_fields:
+ tool_call["provider_specific_fields"] = self.provider_specific_fields
+ if self.function_provider_specific_fields:
+ tool_call["function"]["provider_specific_fields"] = self.function_provider_specific_fields
+ return tool_call
+
@dataclass
class LLMResponse:
diff --git a/tests/test_gemini_thought_signature.py b/tests/test_gemini_thought_signature.py
index db57c7f..bc4132c 100644
--- a/tests/test_gemini_thought_signature.py
+++ b/tests/test_gemini_thought_signature.py
@@ -1,6 +1,5 @@
from types import SimpleNamespace
-from nanobot.agent.loop import AgentLoop
from nanobot.providers.base import ToolCallRequest
from nanobot.providers.litellm_provider import LiteLLMProvider
@@ -38,7 +37,7 @@ def test_litellm_parse_response_preserves_tool_call_provider_fields() -> None:
assert parsed.tool_calls[0].function_provider_specific_fields == {"inner": "value"}
-def test_agent_loop_replays_tool_call_provider_fields() -> None:
+def test_tool_call_request_serializes_provider_fields() -> None:
tool_call = ToolCallRequest(
id="abc123xyz",
name="read_file",
@@ -47,7 +46,7 @@ def test_agent_loop_replays_tool_call_provider_fields() -> None:
function_provider_specific_fields={"inner": "value"},
)
- message = AgentLoop._build_tool_call_message(tool_call)
+ message = tool_call.to_openai_tool_call()
assert message["provider_specific_fields"] == {"thought_signature": "signed-token"}
assert message["function"]["provider_specific_fields"] == {"inner": "value"}
From d0b4f0d70d025ba3ffa0a9127b280d8325bb698f Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Wed, 11 Mar 2026 07:57:12 +0000
Subject: [PATCH 14/36] feat(wecom): add WeCom channel with SDK pinned to
GitHub tag v0.1.2
---
README.md | 25 ++++++++++++++-----------
nanobot/channels/manager.py | 1 -
nanobot/channels/wecom.py | 8 ++++----
nanobot/config/schema.py | 2 +-
pyproject.toml | 4 +++-
5 files changed, 22 insertions(+), 18 deletions(-)
diff --git a/README.md b/README.md
index 5be0ce5..6e8211e 100644
--- a/README.md
+++ b/README.md
@@ -208,7 +208,7 @@ Connect nanobot to your favorite chat platform.
| **Slack** | Bot token + App-Level token |
| **Email** | IMAP/SMTP credentials |
| **QQ** | App ID + App Secret |
-| **Wecom** | Bot ID + App Secret |
+| **Wecom** | Bot ID + Bot Secret |
Telegram (Recommended)
@@ -683,12 +683,17 @@ nanobot gateway
Uses **WebSocket** long connection — no public IP required.
-**1. Create a wecom bot**
+**1. Install the optional dependency**
-In the client's workspace, click on "Intelligent Robot" to create a robot and choose API mode for creation.
-Select to create in "long connection" mode, and obtain Bot ID and Secret.
+```bash
+pip install nanobot-ai[wecom]
+```
-**2. Configure**
+**2. Create a WeCom AI Bot**
+
+Go to the WeCom admin console → Intelligent Robot → Create Robot → select **API mode** with **long connection**. Copy the Bot ID and Secret.
+
+**3. Configure**
```json
{
@@ -696,23 +701,21 @@ Select to create in "long connection" mode, and obtain Bot ID and Secret.
"wecom": {
"enabled": true,
"botId": "your_bot_id",
- "secret": "your_secret",
- "allowFrom": [
- "your_id"
- ]
+ "secret": "your_bot_secret",
+ "allowFrom": ["your_id"]
}
}
}
```
-**3. Run**
+**4. Run**
```bash
nanobot gateway
```
> [!TIP]
-> wecom uses WebSocket to receive messages — no webhook or public IP needed!
+> WeCom uses WebSocket to receive messages — no webhook or public IP needed!
diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py
index 369795a..2c5cd3f 100644
--- a/nanobot/channels/manager.py
+++ b/nanobot/channels/manager.py
@@ -156,7 +156,6 @@ class ChannelManager:
self.channels["wecom"] = WecomChannel(
self.config.channels.wecom,
self.bus,
- groq_api_key=self.config.providers.groq.api_key,
)
logger.info("WeCom channel enabled")
except ImportError as e:
diff --git a/nanobot/channels/wecom.py b/nanobot/channels/wecom.py
index dc97311..1c44451 100644
--- a/nanobot/channels/wecom.py
+++ b/nanobot/channels/wecom.py
@@ -2,6 +2,7 @@
import asyncio
import importlib.util
+import os
from collections import OrderedDict
from typing import Any
@@ -36,10 +37,9 @@ class WecomChannel(BaseChannel):
name = "wecom"
- def __init__(self, config: WecomConfig, bus: MessageBus, groq_api_key: str = ""):
+ def __init__(self, config: WecomConfig, bus: MessageBus):
super().__init__(config, bus)
self.config: WecomConfig = config
- self.groq_api_key = groq_api_key
self._client: Any = None
self._processed_message_ids: OrderedDict[str, None] = OrderedDict()
self._loop: asyncio.AbstractEventLoop | None = None
@@ -50,7 +50,7 @@ class WecomChannel(BaseChannel):
async def start(self) -> None:
"""Start the WeCom bot with WebSocket long connection."""
if not WECOM_AVAILABLE:
- logger.error("WeCom SDK not installed. Run: pip install wecom-aibot-sdk-python")
+ logger.error("WeCom SDK not installed. Run: pip install nanobot-ai[wecom]")
return
if not self.config.bot_id or not self.config.secret:
@@ -213,7 +213,6 @@ class WecomChannel(BaseChannel):
if file_url and aes_key:
file_path = await self._download_and_save_media(file_url, aes_key, "image")
if file_path:
- import os
filename = os.path.basename(file_path)
content_parts.append(f"[image: {filename}]\n[Image: source: {file_path}]")
else:
@@ -308,6 +307,7 @@ class WecomChannel(BaseChannel):
media_dir = get_media_dir("wecom")
if not filename:
filename = fname or f"{media_type}_{hash(file_url) % 100000}"
+ filename = os.path.basename(filename)
file_path = media_dir / filename
file_path.write_bytes(data)
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index b772d18..bb0d286 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -208,7 +208,7 @@ class WecomConfig(Base):
secret: str = "" # Bot Secret from WeCom AI Bot platform
allow_from: list[str] = Field(default_factory=list) # Allowed user IDs
welcome_message: str = "" # Welcome message for enter_chat event
- react_emoji: str = "eyes" # Emoji for message reactions
+
class ChannelsConfig(Base):
"""Configuration for chat channels."""
diff --git a/pyproject.toml b/pyproject.toml
index 0582be6..9868513 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -44,11 +44,13 @@ dependencies = [
"json-repair>=0.57.0,<1.0.0",
"chardet>=3.0.2,<6.0.0",
"openai>=2.8.0",
- "wecom-aibot-sdk-python>=0.1.2",
"tiktoken>=0.12.0,<1.0.0",
]
[project.optional-dependencies]
+wecom = [
+ "wecom-aibot-sdk-python @ git+https://github.com/chengyongru/wecom_aibot_sdk.git@v0.1.2",
+]
matrix = [
"matrix-nio[e2e]>=0.25.2",
"mistune>=3.0.0,<4.0.0",
From 7ceddcded643432f0f4b78aa22de7ad107b61f3a Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Wed, 11 Mar 2026 08:04:14 +0000
Subject: [PATCH 15/36] fix(wecom): await async disconnect, add SDK attribution
in README
---
README.md | 7 +++----
nanobot/channels/wecom.py | 2 +-
2 files changed, 4 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index 6e8211e..2a49214 100644
--- a/README.md
+++ b/README.md
@@ -681,7 +681,9 @@ nanobot gateway
Wecom (企业微信)
-Uses **WebSocket** long connection — no public IP required.
+> Here we use [wecom-aibot-sdk-python](https://github.com/chengyongru/wecom_aibot_sdk) (community Python version of the official [@wecom/aibot-node-sdk](https://www.npmjs.com/package/@wecom/aibot-node-sdk)).
+>
+> Uses **WebSocket** long connection — no public IP required.
**1. Install the optional dependency**
@@ -714,9 +716,6 @@ Go to the WeCom admin console → Intelligent Robot → Create Robot → select
nanobot gateway
```
-> [!TIP]
-> WeCom uses WebSocket to receive messages — no webhook or public IP needed!
-
## 🌐 Agent Social Network
diff --git a/nanobot/channels/wecom.py b/nanobot/channels/wecom.py
index 1c44451..72be9e2 100644
--- a/nanobot/channels/wecom.py
+++ b/nanobot/channels/wecom.py
@@ -98,7 +98,7 @@ class WecomChannel(BaseChannel):
"""Stop the WeCom bot."""
self._running = False
if self._client:
- self._client.disconnect()
+ await self._client.disconnect()
logger.info("WeCom bot stopped")
async def _on_connected(self, frame: Any) -> None:
From 486df1ddbd8db4fb248115851254b8fbb03c09f0 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Wed, 11 Mar 2026 08:10:38 +0000
Subject: [PATCH 16/36] docs: update table of contents in README
---
README.md | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/README.md b/README.md
index 2a49214..ed4e8e7 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,25 @@
📏 Real-time line count: run `bash core_agent_lines.sh` to verify anytime.
+## Table of Contents
+
+- [News](#-news)
+- [Key Features](#key-features-of-nanobot)
+- [Architecture](#️-architecture)
+- [Features](#-features)
+- [Install](#-install)
+- [Quick Start](#-quick-start)
+- [Chat Apps](#-chat-apps)
+- [Agent Social Network](#-agent-social-network)
+- [Configuration](#️-configuration)
+- [Multiple Instances](#-multiple-instances)
+- [CLI Reference](#-cli-reference)
+- [Docker](#-docker)
+- [Linux Service](#-linux-service)
+- [Project Structure](#-project-structure)
+- [Contribute & Roadmap](#-contribute--roadmap)
+- [Star History](#-star-history)
+
## 📢 News
- **2026-03-08** 🚀 Released **v0.1.4.post4** — a reliability-packed release with safer defaults, better multi-instance support, sturdier MCP, and major channel and provider improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post4) for details.
From ec87946c04ccf4d453ffea02febcb747139c415c Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Wed, 11 Mar 2026 08:11:28 +0000
Subject: [PATCH 17/36] docs: update table of contents position
---
README.md | 38 +++++++++++++++++++-------------------
1 file changed, 19 insertions(+), 19 deletions(-)
diff --git a/README.md b/README.md
index ed4e8e7..f0e1a6b 100644
--- a/README.md
+++ b/README.md
@@ -18,25 +18,6 @@
📏 Real-time line count: run `bash core_agent_lines.sh` to verify anytime.
-## Table of Contents
-
-- [News](#-news)
-- [Key Features](#key-features-of-nanobot)
-- [Architecture](#️-architecture)
-- [Features](#-features)
-- [Install](#-install)
-- [Quick Start](#-quick-start)
-- [Chat Apps](#-chat-apps)
-- [Agent Social Network](#-agent-social-network)
-- [Configuration](#️-configuration)
-- [Multiple Instances](#-multiple-instances)
-- [CLI Reference](#-cli-reference)
-- [Docker](#-docker)
-- [Linux Service](#-linux-service)
-- [Project Structure](#-project-structure)
-- [Contribute & Roadmap](#-contribute--roadmap)
-- [Star History](#-star-history)
-
## 📢 News
- **2026-03-08** 🚀 Released **v0.1.4.post4** — a reliability-packed release with safer defaults, better multi-instance support, sturdier MCP, and major channel and provider improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post4) for details.
@@ -97,6 +78,25 @@
+## Table of Contents
+
+- [News](#-news)
+- [Key Features](#key-features-of-nanobot)
+- [Architecture](#️-architecture)
+- [Features](#-features)
+- [Install](#-install)
+- [Quick Start](#-quick-start)
+- [Chat Apps](#-chat-apps)
+- [Agent Social Network](#-agent-social-network)
+- [Configuration](#️-configuration)
+- [Multiple Instances](#-multiple-instances)
+- [CLI Reference](#-cli-reference)
+- [Docker](#-docker)
+- [Linux Service](#-linux-service)
+- [Project Structure](#-project-structure)
+- [Contribute & Roadmap](#-contribute--roadmap)
+- [Star History](#-star-history)
+
## ✨ Features
From 4478838424496b6c233c5402d7fa205f33c683e6 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Wed, 11 Mar 2026 08:42:12 +0000
Subject: [PATCH 18/36] fix(pr-1863): complete Ollama provider routing and
README docs
---
README.md | 32 ++++++++++++++++++++++++++++++++
nano.2091796.save | 2 ++
nano.2095802.save | 2 ++
nanobot/config/schema.py | 13 +++++++++++--
tests/test_commands.py | 29 +++++++++++++++++++++++++++++
5 files changed, 76 insertions(+), 2 deletions(-)
create mode 100644 nano.2091796.save
create mode 100644 nano.2095802.save
diff --git a/README.md b/README.md
index f0e1a6b..8dba2d7 100644
--- a/README.md
+++ b/README.md
@@ -778,6 +778,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) |
+| `ollama` | LLM (local, Ollama) | — |
| `vllm` | LLM (local, any OpenAI-compatible server) | — |
| `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex` |
| `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` |
@@ -843,6 +844,37 @@ Connects directly to any OpenAI-compatible endpoint — LM Studio, llama.cpp, To
+
+Ollama (local)
+
+Run a local model with Ollama, then add to config:
+
+**1. Start Ollama** (example):
+```bash
+ollama run llama3.2
+```
+
+**2. Add to config** (partial — merge into `~/.nanobot/config.json`):
+```json
+{
+ "providers": {
+ "ollama": {
+ "apiBase": "http://localhost:11434"
+ }
+ },
+ "agents": {
+ "defaults": {
+ "provider": "ollama",
+ "model": "llama3.2"
+ }
+ }
+}
+```
+
+> `provider: "auto"` also works when `providers.ollama.apiBase` is configured, but setting `"provider": "ollama"` is the clearest option.
+
+
+
vLLM (local / OpenAI-compatible)
diff --git a/nano.2091796.save b/nano.2091796.save
new file mode 100644
index 0000000..6953168
--- /dev/null
+++ b/nano.2091796.save
@@ -0,0 +1,2 @@
+da activate base
+
diff --git a/nano.2095802.save b/nano.2095802.save
new file mode 100644
index 0000000..6953168
--- /dev/null
+++ b/nano.2095802.save
@@ -0,0 +1,2 @@
+da activate base
+
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index d2ef713..1b26dd7 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -395,6 +395,15 @@ class Config(BaseSettings):
if spec.is_oauth or spec.is_local or p.api_key:
return p, spec.name
+ # Fallback: configured local providers can route models without
+ # provider-specific keywords (for example plain "llama3.2" on Ollama).
+ for spec in PROVIDERS:
+ if not spec.is_local:
+ continue
+ p = getattr(self.providers, spec.name, None)
+ if p and p.api_base:
+ return p, spec.name
+
# Fallback: gateways first, then others (follows registry order)
# OAuth providers are NOT valid fallbacks — they require explicit model selection
for spec in PROVIDERS:
@@ -421,7 +430,7 @@ class Config(BaseSettings):
return p.api_key if p else None
def get_api_base(self, model: str | None = None) -> str | None:
- """Get API base URL for the given model. Applies default URLs for known gateways."""
+ """Get API base URL for the given model. Applies default URLs for gateway/local providers."""
from nanobot.providers.registry import find_by_name
p, name = self._match_provider(model)
@@ -432,7 +441,7 @@ class Config(BaseSettings):
# to avoid polluting the global litellm.api_base.
if name:
spec = find_by_name(name)
- if spec and spec.is_gateway and spec.default_api_base:
+ if spec and (spec.is_gateway or spec.is_local) and spec.default_api_base:
return spec.default_api_base
return None
diff --git a/tests/test_commands.py b/tests/test_commands.py
index 1375a3a..583ef6f 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -114,6 +114,35 @@ def test_config_matches_openai_codex_with_hyphen_prefix():
assert config.get_provider_name() == "openai_codex"
+def test_config_matches_explicit_ollama_prefix_without_api_key():
+ config = Config()
+ config.agents.defaults.model = "ollama/llama3.2"
+
+ assert config.get_provider_name() == "ollama"
+ assert config.get_api_base() == "http://localhost:11434"
+
+
+def test_config_explicit_ollama_provider_uses_default_localhost_api_base():
+ config = Config()
+ config.agents.defaults.provider = "ollama"
+ config.agents.defaults.model = "llama3.2"
+
+ assert config.get_provider_name() == "ollama"
+ assert config.get_api_base() == "http://localhost:11434"
+
+
+def test_config_auto_detects_ollama_from_local_api_base():
+ config = Config.model_validate(
+ {
+ "agents": {"defaults": {"provider": "auto", "model": "llama3.2"}},
+ "providers": {"ollama": {"apiBase": "http://localhost:11434"}},
+ }
+ )
+
+ assert config.get_provider_name() == "ollama"
+ assert config.get_api_base() == "http://localhost:11434"
+
+
def test_find_by_model_prefers_explicit_prefix_over_generic_codex_keyword():
spec = find_by_model("github-copilot/gpt-5.3-codex")
From 89eff6f573d52af025ae9cb7e9db6ea8a0ad698f Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Wed, 11 Mar 2026 08:44:38 +0000
Subject: [PATCH 19/36] chore: remove stray nano backup files
---
.gitignore | 1 +
nano.2091796.save | 2 --
nano.2095802.save | 2 --
3 files changed, 1 insertion(+), 4 deletions(-)
delete mode 100644 nano.2091796.save
delete mode 100644 nano.2095802.save
diff --git a/.gitignore b/.gitignore
index 374875a..c50cab8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,4 +20,5 @@ __pycache__/
poetry.lock
.pytest_cache/
botpy.log
+nano.*.save
diff --git a/nano.2091796.save b/nano.2091796.save
deleted file mode 100644
index 6953168..0000000
--- a/nano.2091796.save
+++ /dev/null
@@ -1,2 +0,0 @@
-da activate base
-
diff --git a/nano.2095802.save b/nano.2095802.save
deleted file mode 100644
index 6953168..0000000
--- a/nano.2095802.save
+++ /dev/null
@@ -1,2 +0,0 @@
-da activate base
-
From c72c2ce7e2b84fda1fd5933fc28d90137f936d03 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Wed, 11 Mar 2026 09:47:04 +0000
Subject: [PATCH 20/36] refactor: move generation settings to provider level,
eliminate parameter passthrough
---
nanobot/agent/loop.py | 15 ---
nanobot/agent/memory.py | 22 +---
nanobot/agent/subagent.py | 9 --
nanobot/cli/commands.py | 57 +++++----
nanobot/providers/base.py | 38 +++++-
tests/test_memory_consolidation_types.py | 23 ++++
tests/test_provider_retry.py | 35 +++++-
tests/test_subagent_reasoning.py | 144 -----------------------
8 files changed, 120 insertions(+), 223 deletions(-)
delete mode 100644 tests/test_subagent_reasoning.py
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index edf1e8e..b1bfd2f 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -52,9 +52,6 @@ class AgentLoop:
workspace: Path,
model: str | None = None,
max_iterations: int = 40,
- temperature: float = 0.1,
- max_tokens: int = 4096,
- reasoning_effort: str | None = None,
context_window_tokens: int = 65_536,
brave_api_key: str | None = None,
web_proxy: str | None = None,
@@ -72,9 +69,6 @@ class AgentLoop:
self.workspace = workspace
self.model = model or provider.get_default_model()
self.max_iterations = max_iterations
- self.temperature = temperature
- self.max_tokens = max_tokens
- self.reasoning_effort = reasoning_effort
self.context_window_tokens = context_window_tokens
self.brave_api_key = brave_api_key
self.web_proxy = web_proxy
@@ -90,9 +84,6 @@ class AgentLoop:
workspace=workspace,
bus=bus,
model=self.model,
- temperature=self.temperature,
- max_tokens=self.max_tokens,
- reasoning_effort=reasoning_effort,
brave_api_key=brave_api_key,
web_proxy=web_proxy,
exec_config=self.exec_config,
@@ -114,9 +105,6 @@ class AgentLoop:
context_window_tokens=context_window_tokens,
build_messages=self.context.build_messages,
get_tool_definitions=self.tools.get_definitions,
- temperature=self.temperature,
- max_tokens=self.max_tokens,
- reasoning_effort=self.reasoning_effort,
)
self._register_default_tools()
@@ -205,9 +193,6 @@ class AgentLoop:
messages=messages,
tools=tool_defs,
model=self.model,
- temperature=self.temperature,
- max_tokens=self.max_tokens,
- reasoning_effort=self.reasoning_effort,
)
if response.has_tool_calls:
diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py
index d79887b..59ba40e 100644
--- a/nanobot/agent/memory.py
+++ b/nanobot/agent/memory.py
@@ -57,7 +57,6 @@ 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
-
class MemoryStore:
"""Two-layer memory: MEMORY.md (long-term facts) + HISTORY.md (grep-searchable log)."""
@@ -99,9 +98,6 @@ class MemoryStore:
messages: list[dict],
provider: LLMProvider,
model: str,
- temperature: float | None = None,
- max_tokens: int | None = None,
- reasoning_effort: str | None = None,
) -> bool:
"""Consolidate the provided message chunk into MEMORY.md + HISTORY.md."""
if not messages:
@@ -124,9 +120,6 @@ class MemoryStore:
],
tools=_SAVE_MEMORY_TOOL,
model=model,
- temperature=temperature,
- max_tokens=max_tokens,
- reasoning_effort=reasoning_effort,
)
if not response.has_tool_calls:
@@ -166,9 +159,6 @@ class MemoryConsolidator:
context_window_tokens: int,
build_messages: Callable[..., list[dict[str, Any]]],
get_tool_definitions: Callable[[], list[dict[str, Any]]],
- temperature: float | None = None,
- max_tokens: int | None = None,
- reasoning_effort: str | None = None,
):
self.store = MemoryStore(workspace)
self.provider = provider
@@ -177,9 +167,6 @@ class MemoryConsolidator:
self.context_window_tokens = context_window_tokens
self._build_messages = build_messages
self._get_tool_definitions = get_tool_definitions
- self._temperature = temperature
- self._max_tokens = max_tokens
- self._reasoning_effort = reasoning_effort
self._locks: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary()
def get_lock(self, session_key: str) -> asyncio.Lock:
@@ -188,14 +175,7 @@ class MemoryConsolidator:
async def consolidate_messages(self, messages: list[dict[str, object]]) -> bool:
"""Archive a selected message chunk into persistent memory."""
- return await self.store.consolidate(
- messages,
- self.provider,
- self.model,
- temperature=self._temperature,
- max_tokens=self._max_tokens,
- reasoning_effort=self._reasoning_effort,
- )
+ return await self.store.consolidate(messages, self.provider, self.model)
def pick_consolidation_boundary(
self,
diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py
index eff0b4f..21b8b32 100644
--- a/nanobot/agent/subagent.py
+++ b/nanobot/agent/subagent.py
@@ -28,9 +28,6 @@ class SubagentManager:
workspace: Path,
bus: MessageBus,
model: str | None = None,
- temperature: float = 0.7,
- max_tokens: int = 4096,
- reasoning_effort: str | None = None,
brave_api_key: str | None = None,
web_proxy: str | None = None,
exec_config: "ExecToolConfig | None" = None,
@@ -41,9 +38,6 @@ class SubagentManager:
self.workspace = workspace
self.bus = bus
self.model = model or provider.get_default_model()
- self.temperature = temperature
- self.max_tokens = max_tokens
- self.reasoning_effort = reasoning_effort
self.brave_api_key = brave_api_key
self.web_proxy = web_proxy
self.exec_config = exec_config or ExecToolConfig()
@@ -128,9 +122,6 @@ class SubagentManager:
messages=messages,
tools=tools.get_definitions(),
model=self.model,
- temperature=self.temperature,
- max_tokens=self.max_tokens,
- reasoning_effort=self.reasoning_effort,
)
if response.has_tool_calls:
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 8387b28..f5ac859 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -215,6 +215,7 @@ def onboard():
def _make_provider(config: Config):
"""Create the appropriate LLM provider from config."""
+ from nanobot.providers.base import GenerationSettings
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
@@ -224,46 +225,50 @@ def _make_provider(config: Config):
# OpenAI Codex (OAuth)
if provider_name == "openai_codex" or model.startswith("openai-codex/"):
- return OpenAICodexProvider(default_model=model)
-
+ provider = OpenAICodexProvider(default_model=model)
# Custom: direct OpenAI-compatible endpoint, bypasses LiteLLM
- from nanobot.providers.custom_provider import CustomProvider
- if provider_name == "custom":
- return CustomProvider(
+ elif provider_name == "custom":
+ from nanobot.providers.custom_provider import CustomProvider
+ provider = CustomProvider(
api_key=p.api_key if p else "no-key",
api_base=config.get_api_base(model) or "http://localhost:8000/v1",
default_model=model,
)
-
# Azure OpenAI: direct Azure OpenAI endpoint with deployment name
- if provider_name == "azure_openai":
+ elif provider_name == "azure_openai":
if not p or not p.api_key or not p.api_base:
console.print("[red]Error: Azure OpenAI requires api_key and api_base.[/red]")
console.print("Set them in ~/.nanobot/config.json under providers.azure_openai section")
console.print("Use the model field to specify the deployment name.")
raise typer.Exit(1)
-
- return AzureOpenAIProvider(
+ provider = AzureOpenAIProvider(
api_key=p.api_key,
api_base=p.api_base,
default_model=model,
)
+ else:
+ from nanobot.providers.litellm_provider import LiteLLMProvider
+ from nanobot.providers.registry import find_by_name
+ spec = find_by_name(provider_name)
+ if not model.startswith("bedrock/") and not (p and p.api_key) and not (spec and (spec.is_oauth or spec.is_local)):
+ console.print("[red]Error: No API key configured.[/red]")
+ console.print("Set one in ~/.nanobot/config.json under providers section")
+ raise typer.Exit(1)
+ provider = LiteLLMProvider(
+ api_key=p.api_key if p else None,
+ api_base=config.get_api_base(model),
+ default_model=model,
+ extra_headers=p.extra_headers if p else None,
+ provider_name=provider_name,
+ )
- from nanobot.providers.litellm_provider import LiteLLMProvider
- from nanobot.providers.registry import find_by_name
- spec = find_by_name(provider_name)
- if not model.startswith("bedrock/") and not (p and p.api_key) and not (spec and (spec.is_oauth or spec.is_local)):
- console.print("[red]Error: No API key configured.[/red]")
- console.print("Set one in ~/.nanobot/config.json under providers section")
- raise typer.Exit(1)
-
- return LiteLLMProvider(
- api_key=p.api_key if p else None,
- api_base=config.get_api_base(model),
- default_model=model,
- extra_headers=p.extra_headers if p else None,
- provider_name=provider_name,
+ defaults = config.agents.defaults
+ provider.generation = GenerationSettings(
+ temperature=defaults.temperature,
+ max_tokens=defaults.max_tokens,
+ reasoning_effort=defaults.reasoning_effort,
)
+ return provider
def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config:
@@ -341,10 +346,7 @@ def gateway(
provider=provider,
workspace=config.workspace_path,
model=config.agents.defaults.model,
- temperature=config.agents.defaults.temperature,
- max_tokens=config.agents.defaults.max_tokens,
max_iterations=config.agents.defaults.max_tool_iterations,
- reasoning_effort=config.agents.defaults.reasoning_effort,
context_window_tokens=config.agents.defaults.context_window_tokens,
brave_api_key=config.tools.web.search.api_key or None,
web_proxy=config.tools.web.proxy or None,
@@ -527,10 +529,7 @@ def agent(
provider=provider,
workspace=config.workspace_path,
model=config.agents.defaults.model,
- temperature=config.agents.defaults.temperature,
- max_tokens=config.agents.defaults.max_tokens,
max_iterations=config.agents.defaults.max_tool_iterations,
- reasoning_effort=config.agents.defaults.reasoning_effort,
context_window_tokens=config.agents.defaults.context_window_tokens,
brave_api_key=config.tools.web.search.api_key or None,
web_proxy=config.tools.web.proxy or None,
diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py
index a3b6c47..d4ea60d 100644
--- a/nanobot/providers/base.py
+++ b/nanobot/providers/base.py
@@ -32,6 +32,21 @@ class LLMResponse:
return len(self.tool_calls) > 0
+@dataclass(frozen=True)
+class GenerationSettings:
+ """Default generation parameters for LLM calls.
+
+ Stored on the provider so every call site inherits the same defaults
+ without having to pass temperature / max_tokens / reasoning_effort
+ through every layer. Individual call sites can still override by
+ passing explicit keyword arguments to chat() / chat_with_retry().
+ """
+
+ temperature: float = 0.7
+ max_tokens: int = 4096
+ reasoning_effort: str | None = None
+
+
class LLMProvider(ABC):
"""
Abstract base class for LLM providers.
@@ -56,9 +71,12 @@ class LLMProvider(ABC):
"temporarily unavailable",
)
+ _SENTINEL = object()
+
def __init__(self, api_key: str | None = None, api_base: str | None = None):
self.api_key = api_key
self.api_base = api_base
+ self.generation: GenerationSettings = GenerationSettings()
@staticmethod
def _sanitize_empty_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
@@ -155,11 +173,23 @@ class LLMProvider(ABC):
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
- max_tokens: int = 4096,
- temperature: float = 0.7,
- reasoning_effort: str | None = None,
+ max_tokens: object = _SENTINEL,
+ temperature: object = _SENTINEL,
+ reasoning_effort: object = _SENTINEL,
) -> LLMResponse:
- """Call chat() with retry on transient provider failures."""
+ """Call chat() with retry on transient provider failures.
+
+ Parameters default to ``self.generation`` when not explicitly passed,
+ so callers no longer need to thread temperature / max_tokens /
+ reasoning_effort through every layer.
+ """
+ if max_tokens is self._SENTINEL:
+ max_tokens = self.generation.max_tokens
+ if temperature is self._SENTINEL:
+ temperature = self.generation.temperature
+ if reasoning_effort is self._SENTINEL:
+ reasoning_effort = self.generation.reasoning_effort
+
for attempt, delay in enumerate(self._CHAT_RETRY_DELAYS, start=1):
try:
response = await self.chat(
diff --git a/tests/test_memory_consolidation_types.py b/tests/test_memory_consolidation_types.py
index 0263f01..69be858 100644
--- a/tests/test_memory_consolidation_types.py
+++ b/tests/test_memory_consolidation_types.py
@@ -265,3 +265,26 @@ class TestMemoryConsolidationTypeHandling:
assert result is True
assert provider.calls == 2
assert delays == [1]
+
+ @pytest.mark.asyncio
+ async def test_consolidation_delegates_to_provider_defaults(self, tmp_path: Path) -> None:
+ """Consolidation no longer passes generation params — the provider owns them."""
+ store = MemoryStore(tmp_path)
+ provider = AsyncMock()
+ provider.chat_with_retry = AsyncMock(
+ return_value=_make_tool_response(
+ history_entry="[2026-01-01] User discussed testing.",
+ memory_update="# Memory\nUser likes testing.",
+ )
+ )
+ messages = _make_messages(message_count=60)
+
+ result = await store.consolidate(messages, provider, "test-model")
+
+ assert result is True
+ provider.chat_with_retry.assert_awaited_once()
+ _, kwargs = provider.chat_with_retry.await_args
+ assert kwargs["model"] == "test-model"
+ assert "temperature" not in kwargs
+ assert "max_tokens" not in kwargs
+ assert "reasoning_effort" not in kwargs
diff --git a/tests/test_provider_retry.py b/tests/test_provider_retry.py
index 751ecc3..2420399 100644
--- a/tests/test_provider_retry.py
+++ b/tests/test_provider_retry.py
@@ -2,7 +2,7 @@ import asyncio
import pytest
-from nanobot.providers.base import LLMProvider, LLMResponse
+from nanobot.providers.base import GenerationSettings, LLMProvider, LLMResponse
class ScriptedProvider(LLMProvider):
@@ -10,9 +10,11 @@ class ScriptedProvider(LLMProvider):
super().__init__()
self._responses = list(responses)
self.calls = 0
+ self.last_kwargs: dict = {}
async def chat(self, *args, **kwargs) -> LLMResponse:
self.calls += 1
+ self.last_kwargs = kwargs
response = self._responses.pop(0)
if isinstance(response, BaseException):
raise response
@@ -90,3 +92,34 @@ async def test_chat_with_retry_preserves_cancelled_error() -> None:
with pytest.raises(asyncio.CancelledError):
await provider.chat_with_retry(messages=[{"role": "user", "content": "hello"}])
+
+
+@pytest.mark.asyncio
+async def test_chat_with_retry_uses_provider_generation_defaults() -> None:
+ """When callers omit generation params, provider.generation defaults are used."""
+ provider = ScriptedProvider([LLMResponse(content="ok")])
+ provider.generation = GenerationSettings(temperature=0.2, max_tokens=321, reasoning_effort="high")
+
+ await provider.chat_with_retry(messages=[{"role": "user", "content": "hello"}])
+
+ assert provider.last_kwargs["temperature"] == 0.2
+ assert provider.last_kwargs["max_tokens"] == 321
+ assert provider.last_kwargs["reasoning_effort"] == "high"
+
+
+@pytest.mark.asyncio
+async def test_chat_with_retry_explicit_override_beats_defaults() -> None:
+ """Explicit kwargs should override provider.generation defaults."""
+ provider = ScriptedProvider([LLMResponse(content="ok")])
+ provider.generation = GenerationSettings(temperature=0.2, max_tokens=321, reasoning_effort="high")
+
+ await provider.chat_with_retry(
+ messages=[{"role": "user", "content": "hello"}],
+ temperature=0.9,
+ max_tokens=9999,
+ reasoning_effort="low",
+ )
+
+ assert provider.last_kwargs["temperature"] == 0.9
+ assert provider.last_kwargs["max_tokens"] == 9999
+ assert provider.last_kwargs["reasoning_effort"] == "low"
diff --git a/tests/test_subagent_reasoning.py b/tests/test_subagent_reasoning.py
deleted file mode 100644
index 5e70506..0000000
--- a/tests/test_subagent_reasoning.py
+++ /dev/null
@@ -1,144 +0,0 @@
-"""Tests for subagent reasoning_content and thinking_blocks handling."""
-
-from __future__ import annotations
-
-import asyncio
-from pathlib import Path
-from unittest.mock import AsyncMock, MagicMock, patch
-
-import pytest
-
-
-class TestSubagentReasoningContent:
- """Test that subagent properly handles reasoning_content and thinking_blocks."""
-
- @pytest.mark.asyncio
- async def test_subagent_message_includes_reasoning_content(self):
- """Verify reasoning_content is included in assistant messages with tool calls.
-
- This is the fix for issue #1834: Spawn/subagent tool fails with
- Deepseek Reasoner due to missing reasoning_content field.
- """
- from nanobot.agent.subagent import SubagentManager
- from nanobot.bus.queue import MessageBus
- from nanobot.providers.base import LLMResponse, ToolCallRequest
-
- bus = MessageBus()
- provider = MagicMock()
- provider.get_default_model.return_value = "deepseek-reasoner"
-
- # Create a real Path object for workspace
- workspace = Path("/tmp/test_workspace")
- workspace.mkdir(parents=True, exist_ok=True)
-
- # Capture messages that are sent to the provider
- captured_messages = []
-
- async def mock_chat(*args, **kwargs):
- captured_messages.append(kwargs.get("messages", []))
- # Return response with tool calls and reasoning_content
- tool_call = ToolCallRequest(
- id="test-1",
- name="read_file",
- arguments={"path": "/test.txt"},
- )
- return LLMResponse(
- content="",
- tool_calls=[tool_call],
- reasoning_content="I need to read this file first",
- )
-
- provider.chat_with_retry = AsyncMock(side_effect=mock_chat)
-
- mgr = SubagentManager(provider=provider, workspace=workspace, bus=bus)
-
- # Mock the tools registry
- with patch("nanobot.agent.subagent.ToolRegistry") as MockToolRegistry:
- mock_registry = MagicMock()
- mock_registry.get_definitions.return_value = []
- mock_registry.execute = AsyncMock(return_value="file content")
- MockToolRegistry.return_value = mock_registry
-
- result = await mgr.spawn(
- task="Read a file",
- label="test",
- origin_channel="cli",
- origin_chat_id="direct",
- session_key="cli:direct",
- )
-
- # Wait for the task to complete
- await asyncio.sleep(0.5)
-
- # Check the captured messages
- assert len(captured_messages) >= 1
- # Find the assistant message with tool_calls
- found = False
- for msg_list in captured_messages:
- for msg in msg_list:
- if msg.get("role") == "assistant" and msg.get("tool_calls"):
- assert "reasoning_content" in msg, "reasoning_content should be in assistant message with tool_calls"
- assert msg["reasoning_content"] == "I need to read this file first"
- found = True
- assert found, "Should have found an assistant message with tool_calls"
-
- @pytest.mark.asyncio
- async def test_subagent_message_includes_thinking_blocks(self):
- """Verify thinking_blocks is included in assistant messages with tool calls."""
- from nanobot.agent.subagent import SubagentManager
- from nanobot.bus.queue import MessageBus
- from nanobot.providers.base import LLMResponse, ToolCallRequest
-
- bus = MessageBus()
- provider = MagicMock()
- provider.get_default_model.return_value = "claude-sonnet"
-
- workspace = Path("/tmp/test_workspace2")
- workspace.mkdir(parents=True, exist_ok=True)
-
- captured_messages = []
-
- async def mock_chat(*args, **kwargs):
- captured_messages.append(kwargs.get("messages", []))
- tool_call = ToolCallRequest(
- id="test-2",
- name="read_file",
- arguments={"path": "/test.txt"},
- )
- return LLMResponse(
- content="",
- tool_calls=[tool_call],
- thinking_blocks=[
- {"signature": "sig1", "thought": "thinking step 1"},
- {"signature": "sig2", "thought": "thinking step 2"},
- ],
- )
-
- provider.chat_with_retry = AsyncMock(side_effect=mock_chat)
-
- mgr = SubagentManager(provider=provider, workspace=workspace, bus=bus)
-
- with patch("nanobot.agent.subagent.ToolRegistry") as MockToolRegistry:
- mock_registry = MagicMock()
- mock_registry.get_definitions.return_value = []
- mock_registry.execute = AsyncMock(return_value="file content")
- MockToolRegistry.return_value = mock_registry
-
- result = await mgr.spawn(
- task="Read a file",
- label="test",
- origin_channel="cli",
- origin_chat_id="direct",
- )
-
- await asyncio.sleep(0.5)
-
- # Check the captured messages
- found = False
- for msg_list in captured_messages:
- for msg in msg_list:
- if msg.get("role") == "assistant" and msg.get("tool_calls"):
- assert "thinking_blocks" in msg, "thinking_blocks should be in assistant message with tool_calls"
- assert len(msg["thinking_blocks"]) == 2
- found = True
- assert found, "Should have found an assistant message with tool_calls"
From 2c5226550d0083ceb41cf4042925682753e2adb5 Mon Sep 17 00:00:00 2001
From: for13to1
Date: Wed, 11 Mar 2026 20:35:04 +0800
Subject: [PATCH 21/36] feat: allow direct references in hatch metadata for
wecom dep
---
pyproject.toml | 3 +++
1 file changed, 3 insertions(+)
diff --git a/pyproject.toml b/pyproject.toml
index 9868513..a52c0c9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -72,6 +72,9 @@ nanobot = "nanobot.cli.commands:app"
requires = ["hatchling"]
build-backend = "hatchling.build"
+[tool.hatch.metadata]
+allow-direct-references = true
+
[tool.hatch.build.targets.wheel]
packages = ["nanobot"]
From 254cfd48babf74cca4bbe7baedda7b540b897cbb Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Wed, 11 Mar 2026 14:23:19 +0000
Subject: [PATCH 22/36] refactor: auto-discover channels via pkgutil, eliminate
hardcoded registry
---
nanobot/channels/base.py | 18 +++++
nanobot/channels/dingtalk.py | 1 +
nanobot/channels/discord.py | 1 +
nanobot/channels/email.py | 1 +
nanobot/channels/feishu.py | 18 ++---
nanobot/channels/manager.py | 140 ++++-------------------------------
nanobot/channels/matrix.py | 18 +++--
nanobot/channels/mochat.py | 1 +
nanobot/channels/qq.py | 1 +
nanobot/channels/registry.py | 35 +++++++++
nanobot/channels/slack.py | 1 +
nanobot/channels/telegram.py | 16 +---
nanobot/channels/wecom.py | 1 +
nanobot/channels/whatsapp.py | 1 +
nanobot/cli/commands.py | 91 ++++-------------------
15 files changed, 111 insertions(+), 233 deletions(-)
create mode 100644 nanobot/channels/registry.py
diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py
index dc53ba4..74c540a 100644
--- a/nanobot/channels/base.py
+++ b/nanobot/channels/base.py
@@ -1,6 +1,9 @@
"""Base channel interface for chat platforms."""
+from __future__ import annotations
+
from abc import ABC, abstractmethod
+from pathlib import Path
from typing import Any
from loguru import logger
@@ -18,6 +21,8 @@ class BaseChannel(ABC):
"""
name: str = "base"
+ display_name: str = "Base"
+ transcription_api_key: str = ""
def __init__(self, config: Any, bus: MessageBus):
"""
@@ -31,6 +36,19 @@ class BaseChannel(ABC):
self.bus = bus
self._running = False
+ async def transcribe_audio(self, file_path: str | Path) -> str:
+ """Transcribe an audio file via Groq Whisper. Returns empty string on failure."""
+ if not self.transcription_api_key:
+ return ""
+ try:
+ from nanobot.providers.transcription import GroqTranscriptionProvider
+
+ provider = GroqTranscriptionProvider(api_key=self.transcription_api_key)
+ return await provider.transcribe(file_path)
+ except Exception as e:
+ logger.warning("{}: audio transcription failed: {}", self.name, e)
+ return ""
+
@abstractmethod
async def start(self) -> None:
"""
diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py
index cdcba57..4626d95 100644
--- a/nanobot/channels/dingtalk.py
+++ b/nanobot/channels/dingtalk.py
@@ -114,6 +114,7 @@ class DingTalkChannel(BaseChannel):
"""
name = "dingtalk"
+ display_name = "DingTalk"
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"}
_AUDIO_EXTS = {".amr", ".mp3", ".wav", ".ogg", ".m4a", ".aac"}
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm"}
diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py
index 2ee4f77..afa20c9 100644
--- a/nanobot/channels/discord.py
+++ b/nanobot/channels/discord.py
@@ -25,6 +25,7 @@ class DiscordChannel(BaseChannel):
"""Discord channel using Gateway websocket."""
name = "discord"
+ display_name = "Discord"
def __init__(self, config: DiscordConfig, bus: MessageBus):
super().__init__(config, bus)
diff --git a/nanobot/channels/email.py b/nanobot/channels/email.py
index 16771fb..46c2103 100644
--- a/nanobot/channels/email.py
+++ b/nanobot/channels/email.py
@@ -35,6 +35,7 @@ class EmailChannel(BaseChannel):
"""
name = "email"
+ display_name = "Email"
_IMAP_MONTHS = (
"Jan",
"Feb",
diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py
index 0409c32..160b9b4 100644
--- a/nanobot/channels/feishu.py
+++ b/nanobot/channels/feishu.py
@@ -244,11 +244,11 @@ class FeishuChannel(BaseChannel):
"""
name = "feishu"
+ display_name = "Feishu"
- def __init__(self, config: FeishuConfig, bus: MessageBus, groq_api_key: str = ""):
+ def __init__(self, config: FeishuConfig, bus: MessageBus):
super().__init__(config, bus)
self.config: FeishuConfig = config
- self.groq_api_key = groq_api_key
self._client: Any = None
self._ws_client: Any = None
self._ws_thread: threading.Thread | None = None
@@ -928,16 +928,10 @@ class FeishuChannel(BaseChannel):
if file_path:
media_paths.append(file_path)
- # Transcribe audio using Groq Whisper
- if msg_type == "audio" and file_path and self.groq_api_key:
- try:
- from nanobot.providers.transcription import GroqTranscriptionProvider
- transcriber = GroqTranscriptionProvider(api_key=self.groq_api_key)
- transcription = await transcriber.transcribe(file_path)
- if transcription:
- content_text = f"[transcription: {transcription}]"
- except Exception as e:
- logger.warning("Failed to transcribe audio: {}", e)
+ if msg_type == "audio" and file_path:
+ transcription = await self.transcribe_audio(file_path)
+ if transcription:
+ content_text = f"[transcription: {transcription}]"
content_parts.append(content_text)
diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py
index 2c5cd3f..8288ad0 100644
--- a/nanobot/channels/manager.py
+++ b/nanobot/channels/manager.py
@@ -31,135 +31,23 @@ class ChannelManager:
self._init_channels()
def _init_channels(self) -> None:
- """Initialize channels based on config."""
+ """Initialize channels discovered via pkgutil scan."""
+ from nanobot.channels.registry import discover_channel_names, load_channel_class
- # Telegram channel
- if self.config.channels.telegram.enabled:
+ groq_key = self.config.providers.groq.api_key
+
+ for modname in discover_channel_names():
+ section = getattr(self.config.channels, modname, None)
+ if not section or not getattr(section, "enabled", False):
+ continue
try:
- from nanobot.channels.telegram import TelegramChannel
- self.channels["telegram"] = TelegramChannel(
- self.config.channels.telegram,
- self.bus,
- groq_api_key=self.config.providers.groq.api_key,
- )
- logger.info("Telegram channel enabled")
+ cls = load_channel_class(modname)
+ channel = cls(section, self.bus)
+ channel.transcription_api_key = groq_key
+ self.channels[modname] = channel
+ logger.info("{} channel enabled", cls.display_name)
except ImportError as e:
- logger.warning("Telegram channel not available: {}", e)
-
- # WhatsApp channel
- if self.config.channels.whatsapp.enabled:
- try:
- from nanobot.channels.whatsapp import WhatsAppChannel
- self.channels["whatsapp"] = WhatsAppChannel(
- self.config.channels.whatsapp, self.bus
- )
- logger.info("WhatsApp channel enabled")
- except ImportError as e:
- logger.warning("WhatsApp channel not available: {}", e)
-
- # Discord channel
- if self.config.channels.discord.enabled:
- try:
- from nanobot.channels.discord import DiscordChannel
- self.channels["discord"] = DiscordChannel(
- self.config.channels.discord, self.bus
- )
- logger.info("Discord channel enabled")
- except ImportError as e:
- logger.warning("Discord channel not available: {}", e)
-
- # Feishu channel
- if self.config.channels.feishu.enabled:
- try:
- from nanobot.channels.feishu import FeishuChannel
- self.channels["feishu"] = FeishuChannel(
- self.config.channels.feishu, self.bus,
- groq_api_key=self.config.providers.groq.api_key,
- )
- logger.info("Feishu channel enabled")
- except ImportError as e:
- logger.warning("Feishu channel not available: {}", e)
-
- # Mochat channel
- if self.config.channels.mochat.enabled:
- try:
- from nanobot.channels.mochat import MochatChannel
-
- self.channels["mochat"] = MochatChannel(
- self.config.channels.mochat, self.bus
- )
- logger.info("Mochat channel enabled")
- except ImportError as e:
- logger.warning("Mochat channel not available: {}", e)
-
- # DingTalk channel
- if self.config.channels.dingtalk.enabled:
- try:
- from nanobot.channels.dingtalk import DingTalkChannel
- self.channels["dingtalk"] = DingTalkChannel(
- self.config.channels.dingtalk, self.bus
- )
- logger.info("DingTalk channel enabled")
- except ImportError as e:
- logger.warning("DingTalk channel not available: {}", e)
-
- # Email channel
- if self.config.channels.email.enabled:
- try:
- from nanobot.channels.email import EmailChannel
- self.channels["email"] = EmailChannel(
- self.config.channels.email, self.bus
- )
- logger.info("Email channel enabled")
- except ImportError as e:
- logger.warning("Email channel not available: {}", e)
-
- # Slack channel
- if self.config.channels.slack.enabled:
- try:
- from nanobot.channels.slack import SlackChannel
- self.channels["slack"] = SlackChannel(
- self.config.channels.slack, self.bus
- )
- logger.info("Slack channel enabled")
- except ImportError as e:
- logger.warning("Slack channel not available: {}", e)
-
- # QQ channel
- if self.config.channels.qq.enabled:
- try:
- from nanobot.channels.qq import QQChannel
- self.channels["qq"] = QQChannel(
- self.config.channels.qq,
- self.bus,
- )
- logger.info("QQ channel enabled")
- except ImportError as e:
- logger.warning("QQ channel not available: {}", e)
-
- # Matrix channel
- if self.config.channels.matrix.enabled:
- try:
- from nanobot.channels.matrix import MatrixChannel
- self.channels["matrix"] = MatrixChannel(
- self.config.channels.matrix,
- self.bus,
- )
- logger.info("Matrix channel enabled")
- except ImportError as e:
- logger.warning("Matrix channel not available: {}", e)
-
- # WeCom channel
- if self.config.channels.wecom.enabled:
- try:
- from nanobot.channels.wecom import WecomChannel
- self.channels["wecom"] = WecomChannel(
- self.config.channels.wecom,
- self.bus,
- )
- logger.info("WeCom channel enabled")
- except ImportError as e:
- logger.warning("WeCom channel not available: {}", e)
+ logger.warning("{} channel not available: {}", modname, e)
self._validate_allow_from()
diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py
index 63cb0ca..0d7a908 100644
--- a/nanobot/channels/matrix.py
+++ b/nanobot/channels/matrix.py
@@ -37,6 +37,7 @@ except ImportError as e:
) from e
from nanobot.bus.events import OutboundMessage
+from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_data_dir, get_media_dir
from nanobot.utils.helpers import safe_filename
@@ -146,15 +147,15 @@ class MatrixChannel(BaseChannel):
"""Matrix (Element) channel using long-polling sync."""
name = "matrix"
+ display_name = "Matrix"
- def __init__(self, config: Any, bus, *, restrict_to_workspace: bool = False,
- workspace: Path | None = None):
+ def __init__(self, config: Any, bus: MessageBus):
super().__init__(config, bus)
self.client: AsyncClient | None = None
self._sync_task: asyncio.Task | None = None
self._typing_tasks: dict[str, asyncio.Task] = {}
- self._restrict_to_workspace = restrict_to_workspace
- self._workspace = workspace.expanduser().resolve() if workspace else None
+ self._restrict_to_workspace = False
+ self._workspace: Path | None = None
self._server_upload_limit_bytes: int | None = None
self._server_upload_limit_checked = False
@@ -677,7 +678,14 @@ class MatrixChannel(BaseChannel):
parts: list[str] = []
if isinstance(body := getattr(event, "body", None), str) and body.strip():
parts.append(body.strip())
- if marker:
+
+ if attachment and attachment.get("type") == "audio":
+ transcription = await self.transcribe_audio(attachment["path"])
+ if transcription:
+ parts.append(f"[transcription: {transcription}]")
+ else:
+ parts.append(marker)
+ elif marker:
parts.append(marker)
await self._start_typing_keepalive(room.room_id)
diff --git a/nanobot/channels/mochat.py b/nanobot/channels/mochat.py
index 09e31c3..52e246f 100644
--- a/nanobot/channels/mochat.py
+++ b/nanobot/channels/mochat.py
@@ -216,6 +216,7 @@ class MochatChannel(BaseChannel):
"""Mochat channel using socket.io with fallback polling workers."""
name = "mochat"
+ display_name = "Mochat"
def __init__(self, config: MochatConfig, bus: MessageBus):
super().__init__(config, bus)
diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py
index 5ac06e3..792cc12 100644
--- a/nanobot/channels/qq.py
+++ b/nanobot/channels/qq.py
@@ -54,6 +54,7 @@ class QQChannel(BaseChannel):
"""QQ channel using botpy SDK with WebSocket connection."""
name = "qq"
+ display_name = "QQ"
def __init__(self, config: QQConfig, bus: MessageBus):
super().__init__(config, bus)
diff --git a/nanobot/channels/registry.py b/nanobot/channels/registry.py
new file mode 100644
index 0000000..eb30ff7
--- /dev/null
+++ b/nanobot/channels/registry.py
@@ -0,0 +1,35 @@
+"""Auto-discovery for channel modules — no hardcoded registry."""
+
+from __future__ import annotations
+
+import importlib
+import pkgutil
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from nanobot.channels.base import BaseChannel
+
+_INTERNAL = frozenset({"base", "manager", "registry"})
+
+
+def discover_channel_names() -> list[str]:
+ """Return all channel module names by scanning the package (zero imports)."""
+ import nanobot.channels as pkg
+
+ return [
+ name
+ for _, name, ispkg in pkgutil.iter_modules(pkg.__path__)
+ if name not in _INTERNAL and not ispkg
+ ]
+
+
+def load_channel_class(module_name: str) -> type[BaseChannel]:
+ """Import *module_name* and return the first BaseChannel subclass found."""
+ from nanobot.channels.base import BaseChannel as _Base
+
+ mod = importlib.import_module(f"nanobot.channels.{module_name}")
+ for attr in dir(mod):
+ obj = getattr(mod, attr)
+ if isinstance(obj, type) and issubclass(obj, _Base) and obj is not _Base:
+ return obj
+ raise ImportError(f"No BaseChannel subclass in nanobot.channels.{module_name}")
diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py
index 0384d8d..5819212 100644
--- a/nanobot/channels/slack.py
+++ b/nanobot/channels/slack.py
@@ -21,6 +21,7 @@ class SlackChannel(BaseChannel):
"""Slack channel using Socket Mode."""
name = "slack"
+ display_name = "Slack"
def __init__(self, config: SlackConfig, bus: MessageBus):
super().__init__(config, bus)
diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py
index 5b294cc..9f93843 100644
--- a/nanobot/channels/telegram.py
+++ b/nanobot/channels/telegram.py
@@ -155,6 +155,7 @@ class TelegramChannel(BaseChannel):
"""
name = "telegram"
+ display_name = "Telegram"
# Commands registered with Telegram's command menu
BOT_COMMANDS = [
@@ -164,15 +165,9 @@ class TelegramChannel(BaseChannel):
BotCommand("help", "Show available commands"),
]
- def __init__(
- self,
- config: TelegramConfig,
- bus: MessageBus,
- groq_api_key: str = "",
- ):
+ def __init__(self, config: TelegramConfig, bus: MessageBus):
super().__init__(config, bus)
self.config: TelegramConfig = config
- self.groq_api_key = groq_api_key
self._app: Application | None = None
self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies
self._typing_tasks: dict[str, asyncio.Task] = {} # chat_id -> typing loop task
@@ -615,11 +610,8 @@ class TelegramChannel(BaseChannel):
media_paths.append(str(file_path))
- # Handle voice transcription
- if media_type == "voice" or media_type == "audio":
- from nanobot.providers.transcription import GroqTranscriptionProvider
- transcriber = GroqTranscriptionProvider(api_key=self.groq_api_key)
- transcription = await transcriber.transcribe(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}]")
diff --git a/nanobot/channels/wecom.py b/nanobot/channels/wecom.py
index 72be9e2..e0f4ae0 100644
--- a/nanobot/channels/wecom.py
+++ b/nanobot/channels/wecom.py
@@ -36,6 +36,7 @@ class WecomChannel(BaseChannel):
"""
name = "wecom"
+ display_name = "WeCom"
def __init__(self, config: WecomConfig, bus: MessageBus):
super().__init__(config, bus)
diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py
index 1307716..7fffb80 100644
--- a/nanobot/channels/whatsapp.py
+++ b/nanobot/channels/whatsapp.py
@@ -22,6 +22,7 @@ class WhatsAppChannel(BaseChannel):
"""
name = "whatsapp"
+ display_name = "WhatsApp"
def __init__(self, config: WhatsAppConfig, bus: MessageBus):
super().__init__(config, bus)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index f5ac859..dd5e60c 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -683,6 +683,7 @@ app.add_typer(channels_app, name="channels")
@channels_app.command("status")
def channels_status():
"""Show channel status."""
+ from nanobot.channels.registry import discover_channel_names, load_channel_class
from nanobot.config.loader import load_config
config = load_config()
@@ -690,85 +691,19 @@ def channels_status():
table = Table(title="Channel Status")
table.add_column("Channel", style="cyan")
table.add_column("Enabled", style="green")
- table.add_column("Configuration", style="yellow")
- # WhatsApp
- wa = config.channels.whatsapp
- table.add_row(
- "WhatsApp",
- "✓" if wa.enabled else "✗",
- wa.bridge_url
- )
-
- dc = config.channels.discord
- table.add_row(
- "Discord",
- "✓" if dc.enabled else "✗",
- dc.gateway_url
- )
-
- # Feishu
- fs = config.channels.feishu
- fs_config = f"app_id: {fs.app_id[:10]}..." if fs.app_id else "[dim]not configured[/dim]"
- table.add_row(
- "Feishu",
- "✓" if fs.enabled else "✗",
- fs_config
- )
-
- # Mochat
- mc = config.channels.mochat
- mc_base = mc.base_url or "[dim]not configured[/dim]"
- table.add_row(
- "Mochat",
- "✓" if mc.enabled else "✗",
- mc_base
- )
-
- # Telegram
- tg = config.channels.telegram
- tg_config = f"token: {tg.token[:10]}..." if tg.token else "[dim]not configured[/dim]"
- table.add_row(
- "Telegram",
- "✓" if tg.enabled else "✗",
- tg_config
- )
-
- # Slack
- slack = config.channels.slack
- slack_config = "socket" if slack.app_token and slack.bot_token else "[dim]not configured[/dim]"
- table.add_row(
- "Slack",
- "✓" if slack.enabled else "✗",
- slack_config
- )
-
- # DingTalk
- dt = config.channels.dingtalk
- dt_config = f"client_id: {dt.client_id[:10]}..." if dt.client_id else "[dim]not configured[/dim]"
- table.add_row(
- "DingTalk",
- "✓" if dt.enabled else "✗",
- dt_config
- )
-
- # QQ
- qq = config.channels.qq
- qq_config = f"app_id: {qq.app_id[:10]}..." if qq.app_id else "[dim]not configured[/dim]"
- table.add_row(
- "QQ",
- "✓" if qq.enabled else "✗",
- qq_config
- )
-
- # Email
- em = config.channels.email
- em_config = em.imap_host if em.imap_host else "[dim]not configured[/dim]"
- table.add_row(
- "Email",
- "✓" if em.enabled else "✗",
- em_config
- )
+ for modname in sorted(discover_channel_names()):
+ section = getattr(config.channels, modname, None)
+ enabled = section and getattr(section, "enabled", False)
+ try:
+ cls = load_channel_class(modname)
+ display = cls.display_name
+ except ImportError:
+ display = modname.title()
+ table.add_row(
+ display,
+ "[green]\u2713[/green]" if enabled else "[dim]\u2717[/dim]",
+ )
console.print(table)
From 9d0db072a38123d6433156bd0da321ef213ab064 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Wed, 11 Mar 2026 15:43:04 +0000
Subject: [PATCH 23/36] fix: guard quoted home paths in shell tool
---
nanobot/agent/tools/shell.py | 4 ++--
tests/test_tool_validation.py | 13 +++++++++++++
2 files changed, 15 insertions(+), 2 deletions(-)
diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py
index 4726e3c..b650930 100644
--- a/nanobot/agent/tools/shell.py
+++ b/nanobot/agent/tools/shell.py
@@ -155,6 +155,6 @@ class ExecTool(Tool):
@staticmethod
def _extract_absolute_paths(command: str) -> list[str]:
win_paths = re.findall(r"[A-Za-z]:\\[^\s\"'|><;]+", command) # Windows: C:\...
- posix_paths = re.findall(r"(?:^|[\s|>])(/[^\s\"'>]+)", command) # POSIX: /absolute only
- home_paths = re.findall(r"(?:^|[\s|>])(~[^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~
+ posix_paths = re.findall(r"(?:^|[\s|>'\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only
+ home_paths = re.findall(r"(?:^|[\s|>'\"])(~[^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~
return win_paths + posix_paths + home_paths
diff --git a/tests/test_tool_validation.py b/tests/test_tool_validation.py
index cf648bf..e67acbf 100644
--- a/tests/test_tool_validation.py
+++ b/tests/test_tool_validation.py
@@ -115,12 +115,25 @@ def test_exec_extract_absolute_paths_captures_home_paths() -> None:
assert "~/out.txt" in paths
+def test_exec_extract_absolute_paths_captures_quoted_paths() -> None:
+ cmd = 'cat "/tmp/data.txt" "~/.nanobot/config.json"'
+ paths = ExecTool._extract_absolute_paths(cmd)
+ assert "/tmp/data.txt" in paths
+ assert "~/.nanobot/config.json" in paths
+
+
def test_exec_guard_blocks_home_path_outside_workspace(tmp_path) -> None:
tool = ExecTool(restrict_to_workspace=True)
error = tool._guard_command("cat ~/.nanobot/config.json", str(tmp_path))
assert error == "Error: Command blocked by safety guard (path outside working dir)"
+def test_exec_guard_blocks_quoted_home_path_outside_workspace(tmp_path) -> None:
+ tool = ExecTool(restrict_to_workspace=True)
+ error = tool._guard_command('cat "~/.nanobot/config.json"', str(tmp_path))
+ assert error == "Error: Command blocked by safety guard (path outside working dir)"
+
+
# --- cast_params tests ---
From 0d94211a9340c4ecde50601029af608045806601 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Wed, 11 Mar 2026 16:20:11 +0000
Subject: [PATCH 24/36] enhance: improve filesystem & shell tools with
pagination, fallback matching, and smarter output
---
nanobot/agent/tools/filesystem.py | 299 +++++++++++++++++++++---------
nanobot/agent/tools/shell.py | 69 ++++---
tests/test_filesystem_tools.py | 251 +++++++++++++++++++++++++
tests/test_tool_validation.py | 41 ++++
4 files changed, 549 insertions(+), 111 deletions(-)
create mode 100644 tests/test_filesystem_tools.py
diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py
index 7b0b867..02c8331 100644
--- a/nanobot/agent/tools/filesystem.py
+++ b/nanobot/agent/tools/filesystem.py
@@ -1,4 +1,4 @@
-"""File system tools: read, write, edit."""
+"""File system tools: read, write, edit, list."""
import difflib
from pathlib import Path
@@ -23,62 +23,108 @@ def _resolve_path(
return resolved
-class ReadFileTool(Tool):
- """Tool to read file contents."""
-
- _MAX_CHARS = 128_000 # ~128 KB — prevents OOM from reading huge files into LLM context
+class _FsTool(Tool):
+ """Shared base for filesystem tools — common init and path resolution."""
def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None):
self._workspace = workspace
self._allowed_dir = allowed_dir
+ def _resolve(self, path: str) -> Path:
+ return _resolve_path(path, self._workspace, self._allowed_dir)
+
+
+# ---------------------------------------------------------------------------
+# read_file
+# ---------------------------------------------------------------------------
+
+class ReadFileTool(_FsTool):
+ """Read file contents with optional line-based pagination."""
+
+ _MAX_CHARS = 128_000
+ _DEFAULT_LIMIT = 2000
+
@property
def name(self) -> str:
return "read_file"
@property
def description(self) -> str:
- return "Read the contents of a file at the given path."
+ return (
+ "Read the contents of a file. Returns numbered lines. "
+ "Use offset and limit to paginate through large files."
+ )
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
- "properties": {"path": {"type": "string", "description": "The file path to read"}},
+ "properties": {
+ "path": {"type": "string", "description": "The file path to read"},
+ "offset": {
+ "type": "integer",
+ "description": "Line number to start reading from (1-indexed, default 1)",
+ "minimum": 1,
+ },
+ "limit": {
+ "type": "integer",
+ "description": "Maximum number of lines to read (default 2000)",
+ "minimum": 1,
+ },
+ },
"required": ["path"],
}
- async def execute(self, path: str, **kwargs: Any) -> str:
+ async def execute(self, path: str, offset: int = 1, limit: int | None = None, **kwargs: Any) -> str:
try:
- file_path = _resolve_path(path, self._workspace, self._allowed_dir)
- if not file_path.exists():
+ fp = self._resolve(path)
+ if not fp.exists():
return f"Error: File not found: {path}"
- if not file_path.is_file():
+ if not fp.is_file():
return f"Error: Not a file: {path}"
- size = file_path.stat().st_size
- if size > self._MAX_CHARS * 4: # rough upper bound (UTF-8 chars ≤ 4 bytes)
- return (
- f"Error: File too large ({size:,} bytes). "
- f"Use exec tool with head/tail/grep to read portions."
- )
+ all_lines = fp.read_text(encoding="utf-8").splitlines()
+ total = len(all_lines)
- content = file_path.read_text(encoding="utf-8")
- if len(content) > self._MAX_CHARS:
- return content[: self._MAX_CHARS] + f"\n\n... (truncated — file is {len(content):,} chars, limit {self._MAX_CHARS:,})"
- return content
+ if offset < 1:
+ offset = 1
+ if total == 0:
+ return f"(Empty file: {path})"
+ if offset > total:
+ return f"Error: offset {offset} is beyond end of file ({total} lines)"
+
+ start = offset - 1
+ end = min(start + (limit or self._DEFAULT_LIMIT), total)
+ numbered = [f"{start + i + 1}| {line}" for i, line in enumerate(all_lines[start:end])]
+ result = "\n".join(numbered)
+
+ if len(result) > self._MAX_CHARS:
+ trimmed, chars = [], 0
+ for line in numbered:
+ chars += len(line) + 1
+ if chars > self._MAX_CHARS:
+ break
+ trimmed.append(line)
+ end = start + len(trimmed)
+ result = "\n".join(trimmed)
+
+ if end < total:
+ result += f"\n\n(Showing lines {offset}-{end} of {total}. Use offset={end + 1} to continue.)"
+ else:
+ result += f"\n\n(End of file — {total} lines total)"
+ return result
except PermissionError as e:
return f"Error: {e}"
except Exception as e:
- return f"Error reading file: {str(e)}"
+ return f"Error reading file: {e}"
-class WriteFileTool(Tool):
- """Tool to write content to a file."""
+# ---------------------------------------------------------------------------
+# write_file
+# ---------------------------------------------------------------------------
- def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None):
- self._workspace = workspace
- self._allowed_dir = allowed_dir
+class WriteFileTool(_FsTool):
+ """Write content to a file."""
@property
def name(self) -> str:
@@ -101,22 +147,48 @@ class WriteFileTool(Tool):
async def execute(self, path: str, content: str, **kwargs: Any) -> str:
try:
- file_path = _resolve_path(path, self._workspace, self._allowed_dir)
- file_path.parent.mkdir(parents=True, exist_ok=True)
- file_path.write_text(content, encoding="utf-8")
- return f"Successfully wrote {len(content)} bytes to {file_path}"
+ fp = self._resolve(path)
+ fp.parent.mkdir(parents=True, exist_ok=True)
+ fp.write_text(content, encoding="utf-8")
+ return f"Successfully wrote {len(content)} bytes to {fp}"
except PermissionError as e:
return f"Error: {e}"
except Exception as e:
- return f"Error writing file: {str(e)}"
+ return f"Error writing file: {e}"
-class EditFileTool(Tool):
- """Tool to edit a file by replacing text."""
+# ---------------------------------------------------------------------------
+# edit_file
+# ---------------------------------------------------------------------------
- def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None):
- self._workspace = workspace
- self._allowed_dir = allowed_dir
+def _find_match(content: str, old_text: str) -> tuple[str | None, int]:
+ """Locate old_text in content: exact first, then line-trimmed sliding window.
+
+ Both inputs should use LF line endings (caller normalises CRLF).
+ Returns (matched_fragment, count) or (None, 0).
+ """
+ if old_text in content:
+ return old_text, content.count(old_text)
+
+ old_lines = old_text.splitlines()
+ if not old_lines:
+ return None, 0
+ stripped_old = [l.strip() for l in old_lines]
+ content_lines = content.splitlines()
+
+ candidates = []
+ for i in range(len(content_lines) - len(stripped_old) + 1):
+ window = content_lines[i : i + len(stripped_old)]
+ if [l.strip() for l in window] == stripped_old:
+ candidates.append("\n".join(window))
+
+ if candidates:
+ return candidates[0], len(candidates)
+ return None, 0
+
+
+class EditFileTool(_FsTool):
+ """Edit a file by replacing text with fallback matching."""
@property
def name(self) -> str:
@@ -124,7 +196,11 @@ class EditFileTool(Tool):
@property
def description(self) -> str:
- return "Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file."
+ return (
+ "Edit a file by replacing old_text with new_text. "
+ "Supports minor whitespace/line-ending differences. "
+ "Set replace_all=true to replace every occurrence."
+ )
@property
def parameters(self) -> dict[str, Any]:
@@ -132,40 +208,52 @@ class EditFileTool(Tool):
"type": "object",
"properties": {
"path": {"type": "string", "description": "The file path to edit"},
- "old_text": {"type": "string", "description": "The exact text to find and replace"},
+ "old_text": {"type": "string", "description": "The text to find and replace"},
"new_text": {"type": "string", "description": "The text to replace with"},
+ "replace_all": {
+ "type": "boolean",
+ "description": "Replace all occurrences (default false)",
+ },
},
"required": ["path", "old_text", "new_text"],
}
- async def execute(self, path: str, old_text: str, new_text: str, **kwargs: Any) -> str:
+ async def execute(
+ self, path: str, old_text: str, new_text: str,
+ replace_all: bool = False, **kwargs: Any,
+ ) -> str:
try:
- file_path = _resolve_path(path, self._workspace, self._allowed_dir)
- if not file_path.exists():
+ fp = self._resolve(path)
+ if not fp.exists():
return f"Error: File not found: {path}"
- content = file_path.read_text(encoding="utf-8")
+ raw = fp.read_bytes()
+ uses_crlf = b"\r\n" in raw
+ content = raw.decode("utf-8").replace("\r\n", "\n")
+ match, count = _find_match(content, old_text.replace("\r\n", "\n"))
- if old_text not in content:
- return self._not_found_message(old_text, content, path)
+ if match is None:
+ return self._not_found_msg(old_text, content, path)
+ if count > 1 and not replace_all:
+ return (
+ f"Warning: old_text appears {count} times. "
+ "Provide more context to make it unique, or set replace_all=true."
+ )
- # Count occurrences
- count = content.count(old_text)
- if count > 1:
- return f"Warning: old_text appears {count} times. Please provide more context to make it unique."
+ norm_new = new_text.replace("\r\n", "\n")
+ new_content = content.replace(match, norm_new) if replace_all else content.replace(match, norm_new, 1)
+ if uses_crlf:
+ new_content = new_content.replace("\n", "\r\n")
- new_content = content.replace(old_text, new_text, 1)
- file_path.write_text(new_content, encoding="utf-8")
-
- return f"Successfully edited {file_path}"
+ fp.write_bytes(new_content.encode("utf-8"))
+ return f"Successfully edited {fp}"
except PermissionError as e:
return f"Error: {e}"
except Exception as e:
- return f"Error editing file: {str(e)}"
+ return f"Error editing file: {e}"
@staticmethod
- def _not_found_message(old_text: str, content: str, path: str) -> str:
- """Build a helpful error when old_text is not found."""
+ def _not_found_msg(old_text: str, content: str, path: str) -> str:
lines = content.splitlines(keepends=True)
old_lines = old_text.splitlines(keepends=True)
window = len(old_lines)
@@ -177,27 +265,29 @@ class EditFileTool(Tool):
best_ratio, best_start = ratio, i
if best_ratio > 0.5:
- diff = "\n".join(
- difflib.unified_diff(
- old_lines,
- lines[best_start : best_start + window],
- fromfile="old_text (provided)",
- tofile=f"{path} (actual, line {best_start + 1})",
- lineterm="",
- )
- )
+ diff = "\n".join(difflib.unified_diff(
+ old_lines, lines[best_start : best_start + window],
+ fromfile="old_text (provided)",
+ tofile=f"{path} (actual, line {best_start + 1})",
+ lineterm="",
+ ))
return f"Error: old_text not found in {path}.\nBest match ({best_ratio:.0%} similar) at line {best_start + 1}:\n{diff}"
- return (
- f"Error: old_text not found in {path}. No similar text found. Verify the file content."
- )
+ return f"Error: old_text not found in {path}. No similar text found. Verify the file content."
-class ListDirTool(Tool):
- """Tool to list directory contents."""
+# ---------------------------------------------------------------------------
+# list_dir
+# ---------------------------------------------------------------------------
- def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None):
- self._workspace = workspace
- self._allowed_dir = allowed_dir
+class ListDirTool(_FsTool):
+ """List directory contents with optional recursion."""
+
+ _DEFAULT_MAX = 200
+ _IGNORE_DIRS = {
+ ".git", "node_modules", "__pycache__", ".venv", "venv",
+ "dist", "build", ".tox", ".mypy_cache", ".pytest_cache",
+ ".ruff_cache", ".coverage", "htmlcov",
+ }
@property
def name(self) -> str:
@@ -205,34 +295,71 @@ class ListDirTool(Tool):
@property
def description(self) -> str:
- return "List the contents of a directory."
+ return (
+ "List the contents of a directory. "
+ "Set recursive=true to explore nested structure. "
+ "Common noise directories (.git, node_modules, __pycache__, etc.) are auto-ignored."
+ )
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
- "properties": {"path": {"type": "string", "description": "The directory path to list"}},
+ "properties": {
+ "path": {"type": "string", "description": "The directory path to list"},
+ "recursive": {
+ "type": "boolean",
+ "description": "Recursively list all files (default false)",
+ },
+ "max_entries": {
+ "type": "integer",
+ "description": "Maximum entries to return (default 200)",
+ "minimum": 1,
+ },
+ },
"required": ["path"],
}
- async def execute(self, path: str, **kwargs: Any) -> str:
+ async def execute(
+ self, path: str, recursive: bool = False,
+ max_entries: int | None = None, **kwargs: Any,
+ ) -> str:
try:
- dir_path = _resolve_path(path, self._workspace, self._allowed_dir)
- if not dir_path.exists():
+ dp = self._resolve(path)
+ if not dp.exists():
return f"Error: Directory not found: {path}"
- if not dir_path.is_dir():
+ if not dp.is_dir():
return f"Error: Not a directory: {path}"
- items = []
- for item in sorted(dir_path.iterdir()):
- prefix = "📁 " if item.is_dir() else "📄 "
- items.append(f"{prefix}{item.name}")
+ cap = max_entries or self._DEFAULT_MAX
+ items: list[str] = []
+ total = 0
- if not items:
+ if recursive:
+ for item in sorted(dp.rglob("*")):
+ if any(p in self._IGNORE_DIRS for p in item.parts):
+ continue
+ total += 1
+ if len(items) < cap:
+ rel = item.relative_to(dp)
+ items.append(f"{rel}/" if item.is_dir() else str(rel))
+ else:
+ for item in sorted(dp.iterdir()):
+ if item.name in self._IGNORE_DIRS:
+ continue
+ total += 1
+ if len(items) < cap:
+ pfx = "📁 " if item.is_dir() else "📄 "
+ items.append(f"{pfx}{item.name}")
+
+ if not items and total == 0:
return f"Directory {path} is empty"
- return "\n".join(items)
+ result = "\n".join(items)
+ if total > cap:
+ result += f"\n\n(truncated, showing first {cap} of {total} entries)"
+ return result
except PermissionError as e:
return f"Error: {e}"
except Exception as e:
- return f"Error listing directory: {str(e)}"
+ return f"Error listing directory: {e}"
diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py
index b650930..bf1b082 100644
--- a/nanobot/agent/tools/shell.py
+++ b/nanobot/agent/tools/shell.py
@@ -42,6 +42,9 @@ class ExecTool(Tool):
def name(self) -> str:
return "exec"
+ _MAX_TIMEOUT = 600
+ _MAX_OUTPUT = 10_000
+
@property
def description(self) -> str:
return "Execute a shell command and return its output. Use with caution."
@@ -53,22 +56,36 @@ class ExecTool(Tool):
"properties": {
"command": {
"type": "string",
- "description": "The shell command to execute"
+ "description": "The shell command to execute",
},
"working_dir": {
"type": "string",
- "description": "Optional working directory for the command"
- }
+ "description": "Optional working directory for the command",
+ },
+ "timeout": {
+ "type": "integer",
+ "description": (
+ "Timeout in seconds. Increase for long-running commands "
+ "like compilation or installation (default 60, max 600)."
+ ),
+ "minimum": 1,
+ "maximum": 600,
+ },
},
- "required": ["command"]
+ "required": ["command"],
}
-
- async def execute(self, command: str, working_dir: str | None = None, **kwargs: Any) -> str:
+
+ async def execute(
+ self, command: str, working_dir: str | None = None,
+ timeout: int | None = None, **kwargs: Any,
+ ) -> str:
cwd = working_dir or self.working_dir or os.getcwd()
guard_error = self._guard_command(command, cwd)
if guard_error:
return guard_error
-
+
+ effective_timeout = min(timeout or self.timeout, self._MAX_TIMEOUT)
+
env = os.environ.copy()
if self.path_append:
env["PATH"] = env.get("PATH", "") + os.pathsep + self.path_append
@@ -81,44 +98,46 @@ class ExecTool(Tool):
cwd=cwd,
env=env,
)
-
+
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
- timeout=self.timeout
+ timeout=effective_timeout,
)
except asyncio.TimeoutError:
process.kill()
- # Wait for the process to fully terminate so pipes are
- # drained and file descriptors are released.
try:
await asyncio.wait_for(process.wait(), timeout=5.0)
except asyncio.TimeoutError:
pass
- return f"Error: Command timed out after {self.timeout} seconds"
-
+ return f"Error: Command timed out after {effective_timeout} seconds"
+
output_parts = []
-
+
if stdout:
output_parts.append(stdout.decode("utf-8", errors="replace"))
-
+
if stderr:
stderr_text = stderr.decode("utf-8", errors="replace")
if stderr_text.strip():
output_parts.append(f"STDERR:\n{stderr_text}")
-
- if process.returncode != 0:
- output_parts.append(f"\nExit code: {process.returncode}")
-
+
+ output_parts.append(f"\nExit code: {process.returncode}")
+
result = "\n".join(output_parts) if output_parts else "(no output)"
-
- # Truncate very long output
- max_len = 10000
+
+ # Head + tail truncation to preserve both start and end of output
+ max_len = self._MAX_OUTPUT
if len(result) > max_len:
- result = result[:max_len] + f"\n... (truncated, {len(result) - max_len} more chars)"
-
+ half = max_len // 2
+ result = (
+ result[:half]
+ + f"\n\n... ({len(result) - max_len:,} chars truncated) ...\n\n"
+ + result[-half:]
+ )
+
return result
-
+
except Exception as e:
return f"Error executing command: {str(e)}"
diff --git a/tests/test_filesystem_tools.py b/tests/test_filesystem_tools.py
new file mode 100644
index 0000000..db8f256
--- /dev/null
+++ b/tests/test_filesystem_tools.py
@@ -0,0 +1,251 @@
+"""Tests for enhanced filesystem tools: ReadFileTool, EditFileTool, ListDirTool."""
+
+import pytest
+
+from nanobot.agent.tools.filesystem import (
+ EditFileTool,
+ ListDirTool,
+ ReadFileTool,
+ _find_match,
+)
+
+
+# ---------------------------------------------------------------------------
+# ReadFileTool
+# ---------------------------------------------------------------------------
+
+class TestReadFileTool:
+
+ @pytest.fixture()
+ def tool(self, tmp_path):
+ return ReadFileTool(workspace=tmp_path)
+
+ @pytest.fixture()
+ def sample_file(self, tmp_path):
+ f = tmp_path / "sample.txt"
+ f.write_text("\n".join(f"line {i}" for i in range(1, 21)), encoding="utf-8")
+ return f
+
+ @pytest.mark.asyncio
+ async def test_basic_read_has_line_numbers(self, tool, sample_file):
+ result = await tool.execute(path=str(sample_file))
+ assert "1| line 1" in result
+ assert "20| line 20" in result
+
+ @pytest.mark.asyncio
+ async def test_offset_and_limit(self, tool, sample_file):
+ result = await tool.execute(path=str(sample_file), offset=5, limit=3)
+ assert "5| line 5" in result
+ assert "7| line 7" in result
+ assert "8| line 8" not in result
+ assert "Use offset=8 to continue" in result
+
+ @pytest.mark.asyncio
+ async def test_offset_beyond_end(self, tool, sample_file):
+ result = await tool.execute(path=str(sample_file), offset=999)
+ assert "Error" in result
+ assert "beyond end" in result
+
+ @pytest.mark.asyncio
+ async def test_end_of_file_marker(self, tool, sample_file):
+ result = await tool.execute(path=str(sample_file), offset=1, limit=9999)
+ assert "End of file" in result
+
+ @pytest.mark.asyncio
+ async def test_empty_file(self, tool, tmp_path):
+ f = tmp_path / "empty.txt"
+ f.write_text("", encoding="utf-8")
+ result = await tool.execute(path=str(f))
+ assert "Empty file" in result
+
+ @pytest.mark.asyncio
+ async def test_file_not_found(self, tool, tmp_path):
+ result = await tool.execute(path=str(tmp_path / "nope.txt"))
+ assert "Error" in result
+ assert "not found" in result
+
+ @pytest.mark.asyncio
+ async def test_char_budget_trims(self, tool, tmp_path):
+ """When the selected slice exceeds _MAX_CHARS the output is trimmed."""
+ f = tmp_path / "big.txt"
+ # Each line is ~110 chars, 2000 lines ≈ 220 KB > 128 KB limit
+ f.write_text("\n".join("x" * 110 for _ in range(2000)), encoding="utf-8")
+ result = await tool.execute(path=str(f))
+ assert len(result) <= ReadFileTool._MAX_CHARS + 500 # small margin for footer
+ assert "Use offset=" in result
+
+
+# ---------------------------------------------------------------------------
+# _find_match (unit tests for the helper)
+# ---------------------------------------------------------------------------
+
+class TestFindMatch:
+
+ def test_exact_match(self):
+ match, count = _find_match("hello world", "world")
+ assert match == "world"
+ assert count == 1
+
+ def test_exact_no_match(self):
+ match, count = _find_match("hello world", "xyz")
+ assert match is None
+ assert count == 0
+
+ def test_crlf_normalisation(self):
+ # Caller normalises CRLF before calling _find_match, so test with
+ # pre-normalised content to verify exact match still works.
+ content = "line1\nline2\nline3"
+ old_text = "line1\nline2\nline3"
+ match, count = _find_match(content, old_text)
+ assert match is not None
+ assert count == 1
+
+ def test_line_trim_fallback(self):
+ content = " def foo():\n pass\n"
+ old_text = "def foo():\n pass"
+ match, count = _find_match(content, old_text)
+ assert match is not None
+ assert count == 1
+ # The returned match should be the *original* indented text
+ assert " def foo():" in match
+
+ def test_line_trim_multiple_candidates(self):
+ content = " a\n b\n a\n b\n"
+ old_text = "a\nb"
+ match, count = _find_match(content, old_text)
+ assert count == 2
+
+ def test_empty_old_text(self):
+ match, count = _find_match("hello", "")
+ # Empty string is always "in" any string via exact match
+ assert match == ""
+
+
+# ---------------------------------------------------------------------------
+# EditFileTool
+# ---------------------------------------------------------------------------
+
+class TestEditFileTool:
+
+ @pytest.fixture()
+ def tool(self, tmp_path):
+ return EditFileTool(workspace=tmp_path)
+
+ @pytest.mark.asyncio
+ async def test_exact_match(self, tool, tmp_path):
+ f = tmp_path / "a.py"
+ f.write_text("hello world", encoding="utf-8")
+ result = await tool.execute(path=str(f), old_text="world", new_text="earth")
+ assert "Successfully" in result
+ assert f.read_text() == "hello earth"
+
+ @pytest.mark.asyncio
+ async def test_crlf_normalisation(self, tool, tmp_path):
+ f = tmp_path / "crlf.py"
+ f.write_bytes(b"line1\r\nline2\r\nline3")
+ result = await tool.execute(
+ path=str(f), old_text="line1\nline2", new_text="LINE1\nLINE2",
+ )
+ assert "Successfully" in result
+ raw = f.read_bytes()
+ assert b"LINE1" in raw
+ # CRLF line endings should be preserved throughout the file
+ assert b"\r\n" in raw
+
+ @pytest.mark.asyncio
+ async def test_trim_fallback(self, tool, tmp_path):
+ f = tmp_path / "indent.py"
+ f.write_text(" def foo():\n pass\n", encoding="utf-8")
+ result = await tool.execute(
+ path=str(f), old_text="def foo():\n pass", new_text="def bar():\n return 1",
+ )
+ assert "Successfully" in result
+ assert "bar" in f.read_text()
+
+ @pytest.mark.asyncio
+ async def test_ambiguous_match(self, tool, tmp_path):
+ f = tmp_path / "dup.py"
+ f.write_text("aaa\nbbb\naaa\nbbb\n", encoding="utf-8")
+ result = await tool.execute(path=str(f), old_text="aaa\nbbb", new_text="xxx")
+ assert "appears" in result.lower() or "Warning" in result
+
+ @pytest.mark.asyncio
+ async def test_replace_all(self, tool, tmp_path):
+ f = tmp_path / "multi.py"
+ f.write_text("foo bar foo bar foo", encoding="utf-8")
+ result = await tool.execute(
+ path=str(f), old_text="foo", new_text="baz", replace_all=True,
+ )
+ assert "Successfully" in result
+ assert f.read_text() == "baz bar baz bar baz"
+
+ @pytest.mark.asyncio
+ async def test_not_found(self, tool, tmp_path):
+ f = tmp_path / "nf.py"
+ f.write_text("hello", encoding="utf-8")
+ result = await tool.execute(path=str(f), old_text="xyz", new_text="abc")
+ assert "Error" in result
+ assert "not found" in result
+
+
+# ---------------------------------------------------------------------------
+# ListDirTool
+# ---------------------------------------------------------------------------
+
+class TestListDirTool:
+
+ @pytest.fixture()
+ def tool(self, tmp_path):
+ return ListDirTool(workspace=tmp_path)
+
+ @pytest.fixture()
+ def populated_dir(self, tmp_path):
+ (tmp_path / "src").mkdir()
+ (tmp_path / "src" / "main.py").write_text("pass")
+ (tmp_path / "src" / "utils.py").write_text("pass")
+ (tmp_path / "README.md").write_text("hi")
+ (tmp_path / ".git").mkdir()
+ (tmp_path / ".git" / "config").write_text("x")
+ (tmp_path / "node_modules").mkdir()
+ (tmp_path / "node_modules" / "pkg").mkdir()
+ return tmp_path
+
+ @pytest.mark.asyncio
+ async def test_basic_list(self, tool, populated_dir):
+ result = await tool.execute(path=str(populated_dir))
+ assert "README.md" in result
+ assert "src" in result
+ # .git and node_modules should be ignored
+ assert ".git" not in result
+ assert "node_modules" not in result
+
+ @pytest.mark.asyncio
+ async def test_recursive(self, tool, populated_dir):
+ result = await tool.execute(path=str(populated_dir), recursive=True)
+ assert "src/main.py" in result
+ assert "src/utils.py" in result
+ assert "README.md" in result
+ # Ignored dirs should not appear
+ assert ".git" not in result
+ assert "node_modules" not in result
+
+ @pytest.mark.asyncio
+ async def test_max_entries_truncation(self, tool, tmp_path):
+ for i in range(10):
+ (tmp_path / f"file_{i}.txt").write_text("x")
+ result = await tool.execute(path=str(tmp_path), max_entries=3)
+ assert "truncated" in result
+ assert "3 of 10" in result
+
+ @pytest.mark.asyncio
+ async def test_empty_dir(self, tool, tmp_path):
+ d = tmp_path / "empty"
+ d.mkdir()
+ result = await tool.execute(path=str(d))
+ assert "empty" in result.lower()
+
+ @pytest.mark.asyncio
+ async def test_not_found(self, tool, tmp_path):
+ result = await tool.execute(path=str(tmp_path / "nope"))
+ assert "Error" in result
+ assert "not found" in result
diff --git a/tests/test_tool_validation.py b/tests/test_tool_validation.py
index e67acbf..095c041 100644
--- a/tests/test_tool_validation.py
+++ b/tests/test_tool_validation.py
@@ -363,3 +363,44 @@ def test_cast_params_single_value_not_auto_wrapped_to_array() -> None:
assert result["items"] == 5 # Not wrapped to [5]
result = tool.cast_params({"items": "text"})
assert result["items"] == "text" # Not wrapped to ["text"]
+
+
+# --- ExecTool enhancement tests ---
+
+
+async def test_exec_always_returns_exit_code() -> None:
+ """Exit code should appear in output even on success (exit 0)."""
+ tool = ExecTool()
+ result = await tool.execute(command="echo hello")
+ assert "Exit code: 0" in result
+ assert "hello" in result
+
+
+async def test_exec_head_tail_truncation() -> None:
+ """Long output should preserve both head and tail."""
+ tool = ExecTool()
+ # Generate output that exceeds _MAX_OUTPUT
+ big = "A" * 6000 + "\n" + "B" * 6000
+ result = await tool.execute(command=f"echo '{big}'")
+ assert "chars truncated" in result
+ # Head portion should start with As
+ assert result.startswith("A")
+ # Tail portion should end with the exit code which comes after Bs
+ assert "Exit code:" in result
+
+
+async def test_exec_timeout_parameter() -> None:
+ """LLM-supplied timeout should override the constructor default."""
+ tool = ExecTool(timeout=60)
+ # A very short timeout should cause the command to be killed
+ result = await tool.execute(command="sleep 10", timeout=1)
+ assert "timed out" in result
+ assert "1 seconds" in result
+
+
+async def test_exec_timeout_capped_at_max() -> None:
+ """Timeout values above _MAX_TIMEOUT should be clamped."""
+ tool = ExecTool()
+ # Should not raise — just clamp to 600
+ result = await tool.execute(command="echo ok", timeout=9999)
+ assert "Exit code: 0" in result
From 64ab6309d5e309976314e166f9c277d956c5a460 Mon Sep 17 00:00:00 2001
From: chengyongru <2755839590@qq.com>
Date: Thu, 12 Mar 2026 00:38:28 +0800
Subject: [PATCH 25/36] fix: wecom-aibot-sdk-python should use pypi version
---
pyproject.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pyproject.toml b/pyproject.toml
index a52c0c9..58831c9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -49,7 +49,7 @@ dependencies = [
[project.optional-dependencies]
wecom = [
- "wecom-aibot-sdk-python @ git+https://github.com/chengyongru/wecom_aibot_sdk.git@v0.1.2",
+ "wecom-aibot-sdk-python>=0.1.2",
]
matrix = [
"matrix-nio[e2e]>=0.25.2",
From 1eedee0c405123115ace30e400c38370a0a27846 Mon Sep 17 00:00:00 2001
From: John Doe
Date: Thu, 12 Mar 2026 06:23:02 +0700
Subject: [PATCH 26/36] 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 27/36] 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 35260ca1574520dd946f55ed11ae2abfce59260d Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Thu, 12 Mar 2026 02:50:28 +0000
Subject: [PATCH 28/36] fix: raise persisted tool result limit to 16k
---
nanobot/agent/loop.py | 2 +-
tests/test_loop_save_turn.py | 16 +++++++++++++++-
2 files changed, 16 insertions(+), 2 deletions(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index b80c5d0..ac8700c 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -43,7 +43,7 @@ class AgentLoop:
5. Sends responses back
"""
- _TOOL_RESULT_MAX_CHARS = 500
+ _TOOL_RESULT_MAX_CHARS = 16_000
def __init__(
self,
diff --git a/tests/test_loop_save_turn.py b/tests/test_loop_save_turn.py
index aec6d1a..25ba88b 100644
--- a/tests/test_loop_save_turn.py
+++ b/tests/test_loop_save_turn.py
@@ -5,7 +5,7 @@ from nanobot.session.manager import Session
def _mk_loop() -> AgentLoop:
loop = AgentLoop.__new__(AgentLoop)
- loop._TOOL_RESULT_MAX_CHARS = 500
+ loop._TOOL_RESULT_MAX_CHARS = AgentLoop._TOOL_RESULT_MAX_CHARS
return loop
@@ -39,3 +39,17 @@ def test_save_turn_keeps_image_placeholder_after_runtime_strip() -> None:
skip=0,
)
assert session.messages[0]["content"] == [{"type": "text", "text": "[image]"}]
+
+
+def test_save_turn_keeps_tool_results_under_16k() -> None:
+ loop = _mk_loop()
+ session = Session(key="test:tool-result")
+ content = "x" * 12_000
+
+ loop._save_turn(
+ session,
+ [{"role": "tool", "tool_call_id": "call_1", "name": "read_file", "content": content}],
+ skip=0,
+ )
+
+ assert session.messages[0]["content"] == content
From 0a0017ff457f66ee91c2d27edfab7725e0751156 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Thu, 12 Mar 2026 03:08:53 +0000
Subject: [PATCH 29/36] fix: raise tool result history limit to 16k and force
save_memory in consolidation
---
nanobot/agent/memory.py | 1 +
nanobot/providers/azure_openai_provider.py | 7 +++++--
nanobot/providers/base.py | 5 +++++
nanobot/providers/custom_provider.py | 5 +++--
nanobot/providers/litellm_provider.py | 3 ++-
nanobot/providers/openai_codex_provider.py | 3 ++-
6 files changed, 18 insertions(+), 6 deletions(-)
diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py
index 59ba40e..802dd04 100644
--- a/nanobot/agent/memory.py
+++ b/nanobot/agent/memory.py
@@ -120,6 +120,7 @@ class MemoryStore:
],
tools=_SAVE_MEMORY_TOOL,
model=model,
+ tool_choice="required",
)
if not response.has_tool_calls:
diff --git a/nanobot/providers/azure_openai_provider.py b/nanobot/providers/azure_openai_provider.py
index bd79b00..05fbac4 100644
--- a/nanobot/providers/azure_openai_provider.py
+++ b/nanobot/providers/azure_openai_provider.py
@@ -88,6 +88,7 @@ class AzureOpenAIProvider(LLMProvider):
max_tokens: int = 4096,
temperature: float = 0.7,
reasoning_effort: str | None = None,
+ tool_choice: str | dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Prepare the request payload with Azure OpenAI 2024-10-21 compliance."""
payload: dict[str, Any] = {
@@ -106,7 +107,7 @@ class AzureOpenAIProvider(LLMProvider):
if tools:
payload["tools"] = tools
- payload["tool_choice"] = "auto"
+ payload["tool_choice"] = tool_choice or "auto"
return payload
@@ -118,6 +119,7 @@ class AzureOpenAIProvider(LLMProvider):
max_tokens: int = 4096,
temperature: float = 0.7,
reasoning_effort: str | None = None,
+ tool_choice: str | dict[str, Any] | None = None,
) -> LLMResponse:
"""
Send a chat completion request to Azure OpenAI.
@@ -137,7 +139,8 @@ class AzureOpenAIProvider(LLMProvider):
url = self._build_chat_url(deployment_name)
headers = self._build_headers()
payload = self._prepare_request_payload(
- deployment_name, messages, tools, max_tokens, temperature, reasoning_effort
+ deployment_name, messages, tools, max_tokens, temperature, reasoning_effort,
+ tool_choice=tool_choice,
)
try:
diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py
index 15a10ff..114a948 100644
--- a/nanobot/providers/base.py
+++ b/nanobot/providers/base.py
@@ -166,6 +166,7 @@ class LLMProvider(ABC):
max_tokens: int = 4096,
temperature: float = 0.7,
reasoning_effort: str | None = None,
+ tool_choice: str | dict[str, Any] | None = None,
) -> LLMResponse:
"""
Send a chat completion request.
@@ -176,6 +177,7 @@ class LLMProvider(ABC):
model: Model identifier (provider-specific).
max_tokens: Maximum tokens in response.
temperature: Sampling temperature.
+ tool_choice: Tool selection strategy ("auto", "required", or specific tool dict).
Returns:
LLMResponse with content and/or tool calls.
@@ -195,6 +197,7 @@ class LLMProvider(ABC):
max_tokens: object = _SENTINEL,
temperature: object = _SENTINEL,
reasoning_effort: object = _SENTINEL,
+ tool_choice: str | dict[str, Any] | None = None,
) -> LLMResponse:
"""Call chat() with retry on transient provider failures.
@@ -218,6 +221,7 @@ class LLMProvider(ABC):
max_tokens=max_tokens,
temperature=temperature,
reasoning_effort=reasoning_effort,
+ tool_choice=tool_choice,
)
except asyncio.CancelledError:
raise
@@ -250,6 +254,7 @@ class LLMProvider(ABC):
max_tokens=max_tokens,
temperature=temperature,
reasoning_effort=reasoning_effort,
+ tool_choice=tool_choice,
)
except asyncio.CancelledError:
raise
diff --git a/nanobot/providers/custom_provider.py b/nanobot/providers/custom_provider.py
index 66df734..f16c69b 100644
--- a/nanobot/providers/custom_provider.py
+++ b/nanobot/providers/custom_provider.py
@@ -25,7 +25,8 @@ class CustomProvider(LLMProvider):
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None,
model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7,
- reasoning_effort: str | None = None) -> LLMResponse:
+ reasoning_effort: str | None = None,
+ tool_choice: str | dict[str, Any] | None = None) -> LLMResponse:
kwargs: dict[str, Any] = {
"model": model or self.default_model,
"messages": self._sanitize_empty_content(messages),
@@ -35,7 +36,7 @@ class CustomProvider(LLMProvider):
if reasoning_effort:
kwargs["reasoning_effort"] = reasoning_effort
if tools:
- kwargs.update(tools=tools, tool_choice="auto")
+ kwargs.update(tools=tools, tool_choice=tool_choice or "auto")
try:
return self._parse(await self._client.chat.completions.create(**kwargs))
except Exception as e:
diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py
index af91c2f..b4508a4 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -214,6 +214,7 @@ class LiteLLMProvider(LLMProvider):
max_tokens: int = 4096,
temperature: float = 0.7,
reasoning_effort: str | None = None,
+ tool_choice: str | dict[str, Any] | None = None,
) -> LLMResponse:
"""
Send a chat completion request via LiteLLM.
@@ -267,7 +268,7 @@ class LiteLLMProvider(LLMProvider):
if tools:
kwargs["tools"] = tools
- kwargs["tool_choice"] = "auto"
+ kwargs["tool_choice"] = tool_choice or "auto"
try:
response = await acompletion(**kwargs)
diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py
index d04e210..c8f2155 100644
--- a/nanobot/providers/openai_codex_provider.py
+++ b/nanobot/providers/openai_codex_provider.py
@@ -32,6 +32,7 @@ class OpenAICodexProvider(LLMProvider):
max_tokens: int = 4096,
temperature: float = 0.7,
reasoning_effort: str | None = None,
+ tool_choice: str | dict[str, Any] | None = None,
) -> LLMResponse:
model = model or self.default_model
system_prompt, input_items = _convert_messages(messages)
@@ -48,7 +49,7 @@ class OpenAICodexProvider(LLMProvider):
"text": {"verbosity": "medium"},
"include": ["reasoning.encrypted_content"],
"prompt_cache_key": _prompt_cache_key(messages),
- "tool_choice": "auto",
+ "tool_choice": tool_choice or "auto",
"parallel_tool_calls": True,
}
From 64aeeceed02aadb19e51f82d71674024baec4b95 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Thu, 12 Mar 2026 04:33:51 +0000
Subject: [PATCH 30/36] Add /restart command: restart the bot process from any
channel
---
nanobot/agent/loop.py | 43 +++++++++++++-------
tests/test_restart_command.py | 76 +++++++++++++++++++++++++++++++++++
2 files changed, 104 insertions(+), 15 deletions(-)
create mode 100644 tests/test_restart_command.py
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 597f852..5fe0ee0 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -4,8 +4,8 @@ from __future__ import annotations
import asyncio
import json
-import re
import os
+import re
import sys
from contextlib import AsyncExitStack
from pathlib import Path
@@ -258,8 +258,11 @@ class AgentLoop:
except asyncio.TimeoutError:
continue
- if msg.content.strip().lower() == "/stop":
+ cmd = msg.content.strip().lower()
+ if cmd == "/stop":
await self._handle_stop(msg)
+ elif cmd == "/restart":
+ await self._handle_restart(msg)
else:
task = asyncio.create_task(self._dispatch(msg))
self._active_tasks.setdefault(msg.session_key, []).append(task)
@@ -276,11 +279,23 @@ class AgentLoop:
pass
sub_cancelled = await self.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."
+ content = f"Stopped {total} task(s)." if total else "No active task to stop."
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content=content,
))
+ async def _handle_restart(self, msg: InboundMessage) -> None:
+ """Restart the process in-place via os.execv."""
+ await self.bus.publish_outbound(OutboundMessage(
+ channel=msg.channel, chat_id=msg.chat_id, content="Restarting...",
+ ))
+
+ async def _do_restart():
+ await asyncio.sleep(1)
+ os.execv(sys.executable, [sys.executable] + sys.argv)
+
+ asyncio.create_task(_do_restart())
+
async def _dispatch(self, msg: InboundMessage) -> None:
"""Process a message under the global lock."""
async with self._processing_lock:
@@ -375,18 +390,16 @@ class AgentLoop:
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="New session started.")
if cmd == "/help":
- return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
- content="🐈 nanobot commands:\n/new — Start a new conversation\n/stop — Stop the current task\n/help — Show available commands")
- if cmd == "/restart":
- await self.bus.publish_outbound(OutboundMessage(
- channel=msg.channel, chat_id=msg.chat_id, content="🔄 Restarting..."
- ))
- async def _r():
- await asyncio.sleep(1)
- os.execv(sys.executable, [sys.executable] + sys.argv)
- asyncio.create_task(_r())
- return None
-
+ lines = [
+ "🐈 nanobot commands:",
+ "/new — Start a new conversation",
+ "/stop — Stop the current task",
+ "/restart — Restart the bot",
+ "/help — Show available commands",
+ ]
+ return OutboundMessage(
+ channel=msg.channel, chat_id=msg.chat_id, content="\n".join(lines),
+ )
await self.memory_consolidator.maybe_consolidate_by_tokens(session)
self._set_tool_context(msg.channel, msg.chat_id, msg.metadata.get("message_id"))
diff --git a/tests/test_restart_command.py b/tests/test_restart_command.py
new file mode 100644
index 0000000..c495347
--- /dev/null
+++ b/tests/test_restart_command.py
@@ -0,0 +1,76 @@
+"""Tests for /restart slash command."""
+
+from __future__ import annotations
+
+import asyncio
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from nanobot.bus.events import InboundMessage
+
+
+def _make_loop():
+ """Create a minimal AgentLoop with mocked dependencies."""
+ from nanobot.agent.loop import AgentLoop
+ from nanobot.bus.queue import MessageBus
+
+ bus = MessageBus()
+ provider = MagicMock()
+ provider.get_default_model.return_value = "test-model"
+ workspace = MagicMock()
+ workspace.__truediv__ = MagicMock(return_value=MagicMock())
+
+ with patch("nanobot.agent.loop.ContextBuilder"), \
+ patch("nanobot.agent.loop.SessionManager"), \
+ patch("nanobot.agent.loop.SubagentManager"):
+ loop = AgentLoop(bus=bus, provider=provider, workspace=workspace)
+ return loop, bus
+
+
+class TestRestartCommand:
+
+ @pytest.mark.asyncio
+ async def test_restart_sends_message_and_calls_execv(self):
+ loop, bus = _make_loop()
+ msg = InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/restart")
+
+ with patch("nanobot.agent.loop.os.execv") as mock_execv:
+ await loop._handle_restart(msg)
+ out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
+ assert "Restarting" in out.content
+
+ await asyncio.sleep(1.5)
+ mock_execv.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_restart_intercepted_in_run_loop(self):
+ """Verify /restart is handled at the run-loop level, not inside _dispatch."""
+ loop, bus = _make_loop()
+ msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/restart")
+
+ with patch.object(loop, "_handle_restart") as mock_handle:
+ mock_handle.return_value = None
+ await bus.publish_inbound(msg)
+
+ loop._running = True
+ run_task = asyncio.create_task(loop.run())
+ await asyncio.sleep(0.1)
+ loop._running = False
+ run_task.cancel()
+ try:
+ await run_task
+ except asyncio.CancelledError:
+ pass
+
+ mock_handle.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_help_includes_restart(self):
+ loop, bus = _make_loop()
+ msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/help")
+
+ response = await loop._process_message(msg)
+
+ assert response is not None
+ assert "/restart" in response.content
From 95c741db6293f49ad41343432b1e9649aa4d1ef8 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Thu, 12 Mar 2026 04:35:34 +0000
Subject: [PATCH 31/36] docs: update nanobot key features
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 8dba2d7..e887828 100644
--- a/README.md
+++ b/README.md
@@ -64,7 +64,7 @@
## Key Features of nanobot:
-🪶 **Ultra-Lightweight**: Just ~4,000 lines of core agent code — 99% smaller than Clawdbot.
+🪶 **Ultra-Lightweight**: A super lightweight implementation of OpenClaw — 99% smaller, significantly faster.
🔬 **Research-Ready**: Clean, readable code that's easy to understand, modify, and extend for research.
From bd1ce8f1440311d42dcc22c60153964f64d27a94 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Thu, 12 Mar 2026 04:45:57 +0000
Subject: [PATCH 32/36] 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 33/36] =?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 34/36] 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 556cb3e83da2aeb240390e611ccc3a9638fa4235 Mon Sep 17 00:00:00 2001
From: gaoyiman
Date: Thu, 12 Mar 2026 14:58:03 +0800
Subject: [PATCH 35/36] 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 3467a7faa6291d41a81257faa36c6e7f5b9e71cc Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Thu, 12 Mar 2026 15:22:15 +0000
Subject: [PATCH 36/36] 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")