From 66cd21e6eccdba245c4b99a2c5e1fd3b2f791995 Mon Sep 17 00:00:00 2001 From: Zhiwei Li Date: Sat, 14 Feb 2026 20:21:34 +1100 Subject: [PATCH 01/36] feat: add SiliconFlow provider support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SiliconFlow (硅基流动) as an OpenAI-compatible gateway provider. SiliconFlow hosts multiple models (Qwen, DeepSeek, etc.) via an OpenAI-compatible API at https://api.siliconflow.cn/v1. Changes: - Add ProviderSpec for siliconflow in providers/registry.py - Add siliconflow field to ProvidersConfig in config/schema.py Co-authored-by: Cursor --- nanobot/config/schema.py | 1 + nanobot/providers/registry.py | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 60bbc69..6140a65 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -192,6 +192,7 @@ class ProvidersConfig(BaseModel): 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) # 硅基流动 API gateway class GatewayConfig(BaseModel): diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index b9071a0..bf00e31 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -117,6 +117,27 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( model_overrides=(), ), + # SiliconFlow (硅基流动): OpenAI-compatible gateway hosting multiple models. + # strip_model_prefix=False: SiliconFlow model names include org prefix + # (e.g. "Qwen/Qwen2.5-14B-Instruct", "deepseek-ai/DeepSeek-V3") + # which is part of the model ID and must NOT be stripped. + ProviderSpec( + name="siliconflow", + keywords=("siliconflow",), + env_key="OPENAI_API_KEY", # OpenAI-compatible + display_name="SiliconFlow", + litellm_prefix="openai", # → openai/{model} + skip_prefixes=(), + env_extras=(), + is_gateway=True, + is_local=False, + detect_by_key_prefix="", + detect_by_base_keyword="siliconflow", + default_api_base="https://api.siliconflow.cn/v1", + strip_model_prefix=False, + model_overrides=(), + ), + # === Standard providers (matched by model-name keywords) =============== # Anthropic: LiteLLM recognizes "claude-*" natively, no prefix needed. From fbbbdc727ddec942279a5d0ccc3c37b1bc08ab23 Mon Sep 17 00:00:00 2001 From: Oleg Medvedev Date: Sat, 14 Feb 2026 13:38:49 -0600 Subject: [PATCH 02/36] fix(tools): resolve relative file paths against workspace File tools now resolve relative paths (e.g., "test.txt") against the workspace directory instead of the current working directory. This fixes failures when models use simple filenames instead of full paths. - Add workspace parameter to _resolve_path() in filesystem.py - Update all file tools to accept workspace in constructor - Pass workspace when registering tools in AgentLoop --- nanobot/agent/loop.py | 10 +++--- nanobot/agent/tools/filesystem.py | 59 +++++++++++++++++-------------- 2 files changed, 38 insertions(+), 31 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index c256a56..3d9f77b 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -86,12 +86,12 @@ class AgentLoop: def _register_default_tools(self) -> None: """Register the default set of tools.""" - # File tools (restrict to workspace if configured) + # File tools (workspace for relative paths, restrict if configured) allowed_dir = self.workspace if self.restrict_to_workspace else None - self.tools.register(ReadFileTool(allowed_dir=allowed_dir)) - self.tools.register(WriteFileTool(allowed_dir=allowed_dir)) - self.tools.register(EditFileTool(allowed_dir=allowed_dir)) - self.tools.register(ListDirTool(allowed_dir=allowed_dir)) + self.tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) + self.tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) + self.tools.register(EditFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) + self.tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir)) # Shell tool self.tools.register(ExecTool( diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index 6b3254a..419b088 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -6,9 +6,12 @@ from typing import Any from nanobot.agent.tools.base import Tool -def _resolve_path(path: str, allowed_dir: Path | None = None) -> Path: - """Resolve path and optionally enforce directory restriction.""" - resolved = Path(path).expanduser().resolve() +def _resolve_path(path: str, workspace: Path | None = None, allowed_dir: Path | None = None) -> Path: + """Resolve path against workspace (if relative) and enforce directory restriction.""" + p = Path(path).expanduser() + if not p.is_absolute() and workspace: + p = workspace / p + resolved = p.resolve() if allowed_dir and not str(resolved).startswith(str(allowed_dir.resolve())): raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}") return resolved @@ -16,8 +19,9 @@ def _resolve_path(path: str, allowed_dir: Path | None = None) -> Path: class ReadFileTool(Tool): """Tool to read file contents.""" - - def __init__(self, allowed_dir: Path | None = None): + + def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None): + self._workspace = workspace self._allowed_dir = allowed_dir @property @@ -43,12 +47,12 @@ class ReadFileTool(Tool): async def execute(self, path: str, **kwargs: Any) -> str: try: - file_path = _resolve_path(path, self._allowed_dir) + file_path = _resolve_path(path, self._workspace, self._allowed_dir) if not file_path.exists(): return f"Error: File not found: {path}" if not file_path.is_file(): return f"Error: Not a file: {path}" - + content = file_path.read_text(encoding="utf-8") return content except PermissionError as e: @@ -59,8 +63,9 @@ class ReadFileTool(Tool): class WriteFileTool(Tool): """Tool to write content to a file.""" - - def __init__(self, allowed_dir: Path | None = None): + + def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None): + self._workspace = workspace self._allowed_dir = allowed_dir @property @@ -90,10 +95,10 @@ class WriteFileTool(Tool): async def execute(self, path: str, content: str, **kwargs: Any) -> str: try: - file_path = _resolve_path(path, self._allowed_dir) + 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 {path}" + return f"Successfully wrote {len(content)} bytes to {file_path}" except PermissionError as e: return f"Error: {e}" except Exception as e: @@ -102,8 +107,9 @@ class WriteFileTool(Tool): class EditFileTool(Tool): """Tool to edit a file by replacing text.""" - - def __init__(self, allowed_dir: Path | None = None): + + def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None): + self._workspace = workspace self._allowed_dir = allowed_dir @property @@ -137,24 +143,24 @@ class EditFileTool(Tool): async def execute(self, path: str, old_text: str, new_text: str, **kwargs: Any) -> str: try: - file_path = _resolve_path(path, self._allowed_dir) + file_path = _resolve_path(path, self._workspace, self._allowed_dir) if not file_path.exists(): return f"Error: File not found: {path}" - + content = file_path.read_text(encoding="utf-8") - + if old_text not in content: return f"Error: old_text not found in file. Make sure it matches exactly." - + # 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." - + new_content = content.replace(old_text, new_text, 1) file_path.write_text(new_content, encoding="utf-8") - - return f"Successfully edited {path}" + + return f"Successfully edited {file_path}" except PermissionError as e: return f"Error: {e}" except Exception as e: @@ -163,8 +169,9 @@ class EditFileTool(Tool): class ListDirTool(Tool): """Tool to list directory contents.""" - - def __init__(self, allowed_dir: Path | None = None): + + def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None): + self._workspace = workspace self._allowed_dir = allowed_dir @property @@ -190,20 +197,20 @@ class ListDirTool(Tool): async def execute(self, path: str, **kwargs: Any) -> str: try: - dir_path = _resolve_path(path, self._allowed_dir) + dir_path = _resolve_path(path, self._workspace, self._allowed_dir) if not dir_path.exists(): return f"Error: Directory not found: {path}" if not dir_path.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}") - + if not items: return f"Directory {path} is empty" - + return "\n".join(items) except PermissionError as e: return f"Error: {e}" From 8f49b52079137af90aa56cb164848add110f284e Mon Sep 17 00:00:00 2001 From: Kiplangatkorir Date: Mon, 16 Feb 2026 15:22:15 +0300 Subject: [PATCH 03/36] Scope sessions to workspace with legacy fallback --- nanobot/session/manager.py | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index bce12a1..e2d8e5c 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -42,8 +42,30 @@ class Session: self.updated_at = datetime.now() def get_history(self, max_messages: int = 500) -> list[dict[str, Any]]: - """Get recent messages in LLM format (role + content only).""" - return [{"role": m["role"], "content": m["content"]} for m in self.messages[-max_messages:]] + """ + Get recent messages in LLM format. + + Preserves tool metadata for replay/debugging fidelity. + """ + history: list[dict[str, Any]] = [] + for msg in self.messages[-max_messages:]: + llm_msg: dict[str, Any] = { + "role": msg["role"], + "content": msg.get("content", ""), + } + + if msg["role"] == "assistant" and "tool_calls" in msg: + llm_msg["tool_calls"] = msg["tool_calls"] + + if msg["role"] == "tool": + if "tool_call_id" in msg: + llm_msg["tool_call_id"] = msg["tool_call_id"] + if "name" in msg: + llm_msg["name"] = msg["name"] + + history.append(llm_msg) + + return history def clear(self) -> None: """Clear all messages and reset session to initial state.""" @@ -61,13 +83,19 @@ class SessionManager: def __init__(self, workspace: Path): self.workspace = workspace - self.sessions_dir = ensure_dir(Path.home() / ".nanobot" / "sessions") + self.sessions_dir = ensure_dir(self.workspace / "sessions") + self.legacy_sessions_dir = Path.home() / ".nanobot" / "sessions" self._cache: dict[str, Session] = {} def _get_session_path(self, key: str) -> Path: """Get the file path for a session.""" safe_key = safe_filename(key.replace(":", "_")) return self.sessions_dir / f"{safe_key}.jsonl" + + def _get_legacy_session_path(self, key: str) -> Path: + """Get the legacy global session path for backward compatibility.""" + safe_key = safe_filename(key.replace(":", "_")) + return self.legacy_sessions_dir / f"{safe_key}.jsonl" def get_or_create(self, key: str) -> Session: """ @@ -92,6 +120,10 @@ class SessionManager: def _load(self, key: str) -> Session | None: """Load a session from disk.""" path = self._get_session_path(key) + if not path.exists(): + legacy_path = self._get_legacy_session_path(key) + if legacy_path.exists(): + path = legacy_path if not path.exists(): return None From 778a93370a7cfaa17c5efdec09487d1ff67f8389 Mon Sep 17 00:00:00 2001 From: Darye <54469750+DaryeDev@users.noreply.github.com> Date: Tue, 17 Feb 2026 03:52:54 +0100 Subject: [PATCH 04/36] Enable Cron management on CLI Agent. --- nanobot/cli/commands.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 235bfdc..1f6b2f5 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -439,9 +439,10 @@ def agent( logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"), ): """Interact with the agent directly.""" - from nanobot.config.loader import load_config + from nanobot.config.loader import load_config, get_data_dir from nanobot.bus.queue import MessageBus from nanobot.agent.loop import AgentLoop + from nanobot.cron.service import CronService from loguru import logger config = load_config() @@ -449,6 +450,10 @@ def agent( bus = MessageBus() provider = _make_provider(config) + # Create cron service for tool usage (no callback needed for CLI unless running) + cron_store_path = get_data_dir() / "cron" / "jobs.json" + cron = CronService(cron_store_path) + if logs: logger.enable("nanobot") else: @@ -465,6 +470,7 @@ def agent( memory_window=config.agents.defaults.memory_window, brave_api_key=config.tools.web.search.api_key or None, exec_config=config.tools.exec, + cron_service=cron, restrict_to_workspace=config.tools.restrict_to_workspace, mcp_servers=config.tools.mcp_servers, ) From c03f2b670bda14557a75e77865b8a89a6670c409 Mon Sep 17 00:00:00 2001 From: Rajasimman S Date: Tue, 17 Feb 2026 18:50:03 +0530 Subject: [PATCH 05/36] =?UTF-8?q?=F0=9F=90=B3=20feat:=20add=20Docker=20Com?= =?UTF-8?q?pose=20support=20for=20easy=20deployment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add docker-compose.yml with gateway and CLI services, resource limits, and comprehensive documentation for Docker Compose usage. --- README.md | 33 +++++++++++++++++++++++++++++++++ docker-compose.yml | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 docker-compose.yml diff --git a/README.md b/README.md index 0e20449..f5a92fd 100644 --- a/README.md +++ b/README.md @@ -811,6 +811,39 @@ nanobot cron remove > [!TIP] > The `-v ~/.nanobot:/root/.nanobot` flag mounts your local config directory into the container, so your config and workspace persist across container restarts. +### Using Docker Compose (Recommended) + +The easiest way to run nanobot with Docker: + +```bash +# 1. Initialize config (first time only) +docker compose run --rm nanobot-cli onboard + +# 2. Edit config to add API keys +vim ~/.nanobot/config.json + +# 3. Start gateway service +docker compose up -d nanobot-gateway + +# 4. Check logs +docker compose logs -f nanobot-gateway + +# 5. Run CLI commands +docker compose run --rm nanobot-cli status +docker compose run --rm nanobot-cli agent -m "Hello!" + +# 6. Stop services +docker compose down +``` + +**Features:** +- ✅ Resource limits (1 CPU, 1GB memory) +- ✅ Auto-restart on failure +- ✅ Shared configuration using YAML anchors +- ✅ Separate CLI profile for on-demand commands + +### Using Docker directly + Build and run nanobot in a container: ```bash diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..446f5e3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +x-common-config: &common-config + build: + context: . + dockerfile: Dockerfile + volumes: + - ~/.nanobot:/root/.nanobot + +services: + nanobot-gateway: + container_name: nanobot-gateway + <<: *common-config + command: ["gateway"] + restart: unless-stopped + ports: + - 18790:18790 + deploy: + resources: + limits: + cpus: '1' + memory: 1G + reservations: + cpus: '0.25' + memory: 256M + + nanobot-cli: + container_name: nanobot-cli + <<: *common-config + profiles: + - cli + command: ["status"] + stdin_open: true + tty: true From 4d4d6299283f51c31357984e3a34d75518fd0501 Mon Sep 17 00:00:00 2001 From: Simon Guigui Date: Tue, 17 Feb 2026 15:19:21 +0100 Subject: [PATCH 06/36] fix(config): mcpServers env variables should not be converted to snake case --- nanobot/config/loader.py | 55 ++++---------------- nanobot/config/schema.py | 109 ++++++++++++++++++++++++++------------- 2 files changed, 83 insertions(+), 81 deletions(-) diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py index fd7d1e8..560c1f5 100644 --- a/nanobot/config/loader.py +++ b/nanobot/config/loader.py @@ -2,7 +2,6 @@ import json from pathlib import Path -from typing import Any from nanobot.config.schema import Config @@ -21,43 +20,41 @@ def get_data_dir() -> Path: def load_config(config_path: Path | None = None) -> Config: """ Load configuration from file or create default. - + Args: config_path: Optional path to config file. Uses default if not provided. - + Returns: Loaded configuration object. """ path = config_path or get_config_path() - + if path.exists(): try: with open(path) as f: data = json.load(f) data = _migrate_config(data) - return Config.model_validate(convert_keys(data)) + return Config.model_validate(data) except (json.JSONDecodeError, ValueError) as e: print(f"Warning: Failed to load config from {path}: {e}") print("Using default configuration.") - + return Config() def save_config(config: Config, config_path: Path | None = None) -> None: """ Save configuration to file. - + Args: config: Configuration to save. config_path: Optional path to save to. Uses default if not provided. """ path = config_path or get_config_path() path.parent.mkdir(parents=True, exist_ok=True) - - # Convert to camelCase format - data = config.model_dump() - data = convert_to_camel(data) - + + data = config.model_dump(by_alias=True) + with open(path, "w") as f: json.dump(data, f, indent=2) @@ -70,37 +67,3 @@ def _migrate_config(data: dict) -> dict: if "restrictToWorkspace" in exec_cfg and "restrictToWorkspace" not in tools: tools["restrictToWorkspace"] = exec_cfg.pop("restrictToWorkspace") return data - - -def convert_keys(data: Any) -> Any: - """Convert camelCase keys to snake_case for Pydantic.""" - if isinstance(data, dict): - return {camel_to_snake(k): convert_keys(v) for k, v in data.items()} - if isinstance(data, list): - return [convert_keys(item) for item in data] - return data - - -def convert_to_camel(data: Any) -> Any: - """Convert snake_case keys to camelCase.""" - if isinstance(data, dict): - return {snake_to_camel(k): convert_to_camel(v) for k, v in data.items()} - if isinstance(data, list): - return [convert_to_camel(item) for item in data] - return data - - -def camel_to_snake(name: str) -> str: - """Convert camelCase to snake_case.""" - result = [] - for i, char in enumerate(name): - if char.isupper() and i > 0: - result.append("_") - result.append(char.lower()) - return "".join(result) - - -def snake_to_camel(name: str) -> str: - """Convert snake_case to camelCase.""" - components = name.split("_") - return components[0] + "".join(x.title() for x in components[1:]) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 64609ec..0786080 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -2,27 +2,39 @@ from pathlib import Path from pydantic import BaseModel, Field, ConfigDict +from pydantic.alias_generators import to_camel from pydantic_settings import BaseSettings -class WhatsAppConfig(BaseModel): +class Base(BaseModel): + """Base model that accepts both camelCase and snake_case keys.""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class WhatsAppConfig(Base): """WhatsApp channel configuration.""" + enabled: bool = False bridge_url: str = "ws://localhost:3001" bridge_token: str = "" # Shared token for bridge auth (optional, recommended) allow_from: list[str] = Field(default_factory=list) # Allowed phone numbers -class TelegramConfig(BaseModel): +class TelegramConfig(Base): """Telegram channel configuration.""" + enabled: bool = False token: str = "" # Bot token from @BotFather allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames - proxy: str | None = None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" + proxy: str | None = ( + None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" + ) -class FeishuConfig(BaseModel): +class FeishuConfig(Base): """Feishu/Lark channel configuration using WebSocket long connection.""" + enabled: bool = False app_id: str = "" # App ID from Feishu Open Platform app_secret: str = "" # App Secret from Feishu Open Platform @@ -31,24 +43,28 @@ class FeishuConfig(BaseModel): allow_from: list[str] = Field(default_factory=list) # Allowed user open_ids -class DingTalkConfig(BaseModel): +class DingTalkConfig(Base): """DingTalk channel configuration using Stream mode.""" + enabled: bool = False client_id: str = "" # AppKey client_secret: str = "" # AppSecret allow_from: list[str] = Field(default_factory=list) # Allowed staff_ids -class DiscordConfig(BaseModel): +class DiscordConfig(Base): """Discord channel configuration.""" + enabled: bool = False token: str = "" # Bot token from Discord Developer Portal allow_from: list[str] = Field(default_factory=list) # Allowed user IDs gateway_url: str = "wss://gateway.discord.gg/?v=10&encoding=json" intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT -class EmailConfig(BaseModel): + +class EmailConfig(Base): """Email channel configuration (IMAP inbound + SMTP outbound).""" + enabled: bool = False consent_granted: bool = False # Explicit owner permission to access mailbox data @@ -70,7 +86,9 @@ class EmailConfig(BaseModel): from_address: str = "" # Behavior - auto_reply_enabled: bool = True # If false, inbound email is read but no automatic reply is sent + auto_reply_enabled: bool = ( + True # If false, inbound email is read but no automatic reply is sent + ) poll_interval_seconds: int = 30 mark_seen: bool = True max_body_chars: int = 12000 @@ -78,18 +96,21 @@ class EmailConfig(BaseModel): allow_from: list[str] = Field(default_factory=list) # Allowed sender email addresses -class MochatMentionConfig(BaseModel): +class MochatMentionConfig(Base): """Mochat mention behavior configuration.""" + require_in_groups: bool = False -class MochatGroupRule(BaseModel): +class MochatGroupRule(Base): """Mochat per-group mention requirement.""" + require_mention: bool = False -class MochatConfig(BaseModel): +class MochatConfig(Base): """Mochat channel configuration.""" + enabled: bool = False base_url: str = "https://mochat.io" socket_url: str = "" @@ -114,15 +135,17 @@ class MochatConfig(BaseModel): reply_delay_ms: int = 120000 -class SlackDMConfig(BaseModel): +class SlackDMConfig(Base): """Slack DM policy configuration.""" + enabled: bool = True policy: str = "open" # "open" or "allowlist" allow_from: list[str] = Field(default_factory=list) # Allowed Slack user IDs -class SlackConfig(BaseModel): +class SlackConfig(Base): """Slack channel configuration.""" + enabled: bool = False mode: str = "socket" # "socket" supported webhook_path: str = "/slack/events" @@ -134,16 +157,20 @@ class SlackConfig(BaseModel): dm: SlackDMConfig = Field(default_factory=SlackDMConfig) -class QQConfig(BaseModel): +class QQConfig(Base): """QQ channel configuration using botpy SDK.""" + enabled: bool = False app_id: str = "" # 机器人 ID (AppID) from q.qq.com secret: str = "" # 机器人密钥 (AppSecret) from q.qq.com - allow_from: list[str] = Field(default_factory=list) # Allowed user openids (empty = public access) + allow_from: list[str] = Field( + default_factory=list + ) # Allowed user openids (empty = public access) -class ChannelsConfig(BaseModel): +class ChannelsConfig(Base): """Configuration for chat channels.""" + whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig) telegram: TelegramConfig = Field(default_factory=TelegramConfig) discord: DiscordConfig = Field(default_factory=DiscordConfig) @@ -155,8 +182,9 @@ class ChannelsConfig(BaseModel): qq: QQConfig = Field(default_factory=QQConfig) -class AgentDefaults(BaseModel): +class AgentDefaults(Base): """Default agent configuration.""" + workspace: str = "~/.nanobot/workspace" model: str = "anthropic/claude-opus-4-5" max_tokens: int = 8192 @@ -165,20 +193,23 @@ class AgentDefaults(BaseModel): memory_window: int = 50 -class AgentsConfig(BaseModel): +class AgentsConfig(Base): """Agent configuration.""" + defaults: AgentDefaults = Field(default_factory=AgentDefaults) -class ProviderConfig(BaseModel): +class ProviderConfig(Base): """LLM provider configuration.""" + api_key: str = "" api_base: str | None = None extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix) -class ProvidersConfig(BaseModel): +class ProvidersConfig(Base): """Configuration for LLM providers.""" + custom: ProviderConfig = Field(default_factory=ProviderConfig) # Any OpenAI-compatible endpoint anthropic: ProviderConfig = Field(default_factory=ProviderConfig) openai: ProviderConfig = Field(default_factory=ProviderConfig) @@ -196,38 +227,44 @@ class ProvidersConfig(BaseModel): github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth) -class GatewayConfig(BaseModel): +class GatewayConfig(Base): """Gateway/server configuration.""" + host: str = "0.0.0.0" port: int = 18790 -class WebSearchConfig(BaseModel): +class WebSearchConfig(Base): """Web search tool configuration.""" + api_key: str = "" # Brave Search API key max_results: int = 5 -class WebToolsConfig(BaseModel): +class WebToolsConfig(Base): """Web tools configuration.""" + search: WebSearchConfig = Field(default_factory=WebSearchConfig) -class ExecToolConfig(BaseModel): +class ExecToolConfig(Base): """Shell exec tool configuration.""" + timeout: int = 60 -class MCPServerConfig(BaseModel): +class MCPServerConfig(Base): """MCP server connection configuration (stdio or HTTP).""" + command: str = "" # Stdio: command to run (e.g. "npx") args: list[str] = Field(default_factory=list) # Stdio: command arguments env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars url: str = "" # HTTP: streamable HTTP endpoint URL -class ToolsConfig(BaseModel): +class ToolsConfig(Base): """Tools configuration.""" + web: WebToolsConfig = Field(default_factory=WebToolsConfig) exec: ExecToolConfig = Field(default_factory=ExecToolConfig) restrict_to_workspace: bool = False # If true, restrict all tool access to workspace directory @@ -236,20 +273,24 @@ class ToolsConfig(BaseModel): class Config(BaseSettings): """Root configuration for nanobot.""" + agents: AgentsConfig = Field(default_factory=AgentsConfig) channels: ChannelsConfig = Field(default_factory=ChannelsConfig) providers: ProvidersConfig = Field(default_factory=ProvidersConfig) gateway: GatewayConfig = Field(default_factory=GatewayConfig) tools: ToolsConfig = Field(default_factory=ToolsConfig) - + @property def workspace_path(self) -> Path: """Get expanded workspace path.""" return Path(self.agents.defaults.workspace).expanduser() - - def _match_provider(self, model: str | None = None) -> tuple["ProviderConfig | None", str | None]: + + def _match_provider( + self, model: str | None = None + ) -> tuple["ProviderConfig | None", str | None]: """Match provider config and its registry name. Returns (config, spec_name).""" from nanobot.providers.registry import PROVIDERS + model_lower = (model or self.agents.defaults.model).lower() # Match by keyword (order follows PROVIDERS registry) @@ -283,10 +324,11 @@ class Config(BaseSettings): """Get API key for the given model. Falls back to first available key.""" p = self.get_provider(model) 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.""" from nanobot.providers.registry import find_by_name + p, name = self._match_provider(model) if p and p.api_base: return p.api_base @@ -298,8 +340,5 @@ class Config(BaseSettings): if spec and spec.is_gateway and spec.default_api_base: return spec.default_api_base return None - - model_config = ConfigDict( - env_prefix="NANOBOT_", - env_nested_delimiter="__" - ) + + model_config = ConfigDict(env_prefix="NANOBOT_", env_nested_delimiter="__") From 941c3d98264303c7494b98c306c2696499bdc45a Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 17 Feb 2026 17:34:24 +0000 Subject: [PATCH 07/36] style: restore single-line formatting for readability --- nanobot/config/schema.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 0786080..76ec74d 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -27,9 +27,7 @@ class TelegramConfig(Base): enabled: bool = False token: str = "" # Bot token from @BotFather allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames - proxy: str | None = ( - None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" - ) + proxy: str | None = None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" class FeishuConfig(Base): @@ -86,9 +84,7 @@ class EmailConfig(Base): from_address: str = "" # Behavior - auto_reply_enabled: bool = ( - True # If false, inbound email is read but no automatic reply is sent - ) + auto_reply_enabled: bool = True # If false, inbound email is read but no automatic reply is sent poll_interval_seconds: int = 30 mark_seen: bool = True max_body_chars: int = 12000 @@ -163,9 +159,7 @@ class QQConfig(Base): enabled: bool = False app_id: str = "" # 机器人 ID (AppID) from q.qq.com secret: str = "" # 机器人密钥 (AppSecret) from q.qq.com - allow_from: list[str] = Field( - default_factory=list - ) # Allowed user openids (empty = public access) + allow_from: list[str] = Field(default_factory=list) # Allowed user openids (empty = public access) class ChannelsConfig(Base): @@ -285,9 +279,7 @@ class Config(BaseSettings): """Get expanded workspace path.""" return Path(self.agents.defaults.workspace).expanduser() - def _match_provider( - self, model: str | None = None - ) -> tuple["ProviderConfig | None", str | None]: + def _match_provider(self, model: str | None = None) -> tuple["ProviderConfig | None", str | None]: """Match provider config and its registry name. Returns (config, spec_name).""" from nanobot.providers.registry import PROVIDERS From aad1df5b9b13f8bd0e233af7cc26cc47e0615ff4 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 17 Feb 2026 17:55:48 +0000 Subject: [PATCH 08/36] Simplify Docker Compose docs and remove fixed CLI container name --- README.md | 39 ++++++++++----------------------------- docker-compose.yml | 1 - 2 files changed, 10 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index f5a92fd..96ff557 100644 --- a/README.md +++ b/README.md @@ -811,40 +811,21 @@ nanobot cron remove > [!TIP] > The `-v ~/.nanobot:/root/.nanobot` flag mounts your local config directory into the container, so your config and workspace persist across container restarts. -### Using Docker Compose (Recommended) - -The easiest way to run nanobot with Docker: +### Docker Compose ```bash -# 1. Initialize config (first time only) -docker compose run --rm nanobot-cli onboard - -# 2. Edit config to add API keys -vim ~/.nanobot/config.json - -# 3. Start gateway service -docker compose up -d nanobot-gateway - -# 4. Check logs -docker compose logs -f nanobot-gateway - -# 5. Run CLI commands -docker compose run --rm nanobot-cli status -docker compose run --rm nanobot-cli agent -m "Hello!" - -# 6. Stop services -docker compose down +docker compose run --rm nanobot-cli onboard # first-time setup +vim ~/.nanobot/config.json # add API keys +docker compose up -d nanobot-gateway # start gateway ``` -**Features:** -- ✅ Resource limits (1 CPU, 1GB memory) -- ✅ Auto-restart on failure -- ✅ Shared configuration using YAML anchors -- ✅ Separate CLI profile for on-demand commands +```bash +docker compose run --rm nanobot-cli agent -m "Hello!" # run CLI +docker compose logs -f nanobot-gateway # view logs +docker compose down # stop +``` -### Using Docker directly - -Build and run nanobot in a container: +### Docker ```bash # Build the image diff --git a/docker-compose.yml b/docker-compose.yml index 446f5e3..5c27f81 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,7 +23,6 @@ services: memory: 256M nanobot-cli: - container_name: nanobot-cli <<: *common-config profiles: - cli From 05d06b1eb8a2492992da89f7dc0534df158cff4f Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 17 Feb 2026 17:58:36 +0000 Subject: [PATCH 09/36] docs: update line count --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 96ff557..325aa64 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines. -📏 Real-time line count: **3,689 lines** (run `bash core_agent_lines.sh` to verify anytime) +📏 Real-time line count: **3,696 lines** (run `bash core_agent_lines.sh` to verify anytime) ## 📢 News From 831eb07945e0ee4cfb8bdfd927cf2e25cf7ed433 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Wed, 18 Feb 2026 02:00:30 +0800 Subject: [PATCH 10/36] docs: update security guideline --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index af3448c..405ce52 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,7 +5,7 @@ If you discover a security vulnerability in nanobot, please report it by: 1. **DO NOT** open a public GitHub issue -2. Create a private security advisory on GitHub or contact the repository maintainers +2. Create a private security advisory on GitHub or contact the repository maintainers (xubinrencs@gmail.com) 3. Include: - Description of the vulnerability - Steps to reproduce From 72db01db636c4ec2e63b50a7c79dfbdbf758b8a9 Mon Sep 17 00:00:00 2001 From: Hyudryu Date: Tue, 17 Feb 2026 13:42:57 -0800 Subject: [PATCH 11/36] slack: Added replyInThread logic and custom react emoji in config --- nanobot/channels/slack.py | 6 ++++-- nanobot/config/schema.py | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index e5fff63..dca5055 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -152,13 +152,15 @@ class SlackChannel(BaseChannel): text = self._strip_bot_mention(text) - thread_ts = event.get("thread_ts") or event.get("ts") + thread_ts = event.get("thread_ts") + if self.config.reply_in_thread and not thread_ts: + thread_ts = event.get("ts") # Add :eyes: reaction to the triggering message (best-effort) try: if self._web_client and event.get("ts"): await self._web_client.reactions_add( channel=chat_id, - name="eyes", + name=self.config.react_emoji, timestamp=event.get("ts"), ) except Exception as e: diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 76ec74d..3cacbde 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -148,6 +148,8 @@ class SlackConfig(Base): bot_token: str = "" # xoxb-... app_token: str = "" # xapp-... user_token_read_only: bool = True + reply_in_thread: bool = True + react_emoji: str = "eyes" group_policy: str = "mention" # "mention", "open", "allowlist" group_allow_from: list[str] = Field(default_factory=list) # Allowed channel IDs if allowlist dm: SlackDMConfig = Field(default_factory=SlackDMConfig) From b161fa4f9a7521d9868f2bd83d240cc9a15dc21f Mon Sep 17 00:00:00 2001 From: Jeroen Evens Date: Fri, 6 Feb 2026 18:38:18 +0100 Subject: [PATCH 12/36] [github] Add Github Copilot --- nanobot/cli/commands.py | 4 +++- nanobot/config/schema.py | 4 +++- nanobot/providers/litellm_provider.py | 7 +++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 5280d0f..ff3493a 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -892,7 +892,9 @@ def status(): p = getattr(config.providers, spec.name, None) if p is None: continue - if spec.is_local: + if spec.is_oauth: + console.print(f"{spec.label}: [green]✓ (OAuth)[/green]") + elif spec.is_local: # Local deployments show api_base instead of api_key if p.api_base: console.print(f"{spec.label}: [green]✓ {p.api_base}[/green]") diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 76ec74d..99c659c 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -298,7 +298,9 @@ class Config(BaseSettings): if spec.is_oauth: continue p = getattr(self.providers, spec.name, None) - if p and p.api_key: + if p is None: + continue + if p.api_key: return p, spec.name return None, None diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 8cc4e35..43dfbb5 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -38,6 +38,9 @@ class LiteLLMProvider(LLMProvider): # api_key / api_base are fallback for auto-detection. self._gateway = find_gateway(provider_name, api_key, api_base) + # Detect GitHub Copilot (uses OAuth device flow, no API key) + self.is_github_copilot = "github_copilot" in default_model + # Configure environment variables if api_key: self._setup_env(api_key, api_base, default_model) @@ -76,6 +79,10 @@ class LiteLLMProvider(LLMProvider): def _resolve_model(self, model: str) -> str: """Resolve model name by applying provider/gateway prefixes.""" + # GitHub Copilot models pass through directly + if self.is_github_copilot: + return model + if self._gateway: # Gateway mode: apply gateway prefix, skip provider-specific prefixes prefix = self._gateway.litellm_prefix From 16127d49f98cccb47fdf99306f41c38934821bc9 Mon Sep 17 00:00:00 2001 From: Jeroen Evens Date: Tue, 17 Feb 2026 22:50:39 +0100 Subject: [PATCH 13/36] [github] Fix Oauth login --- nanobot/cli/commands.py | 112 +++++++++++++++++++------- nanobot/providers/litellm_provider.py | 7 -- 2 files changed, 82 insertions(+), 37 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index ff3493a..c1104da 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -915,40 +915,92 @@ app.add_typer(provider_app, name="provider") @provider_app.command("login") def provider_login( - provider: str = typer.Argument(..., help="OAuth provider to authenticate with (e.g., 'openai-codex')"), + provider: str = typer.Argument(..., help="OAuth provider to authenticate with (e.g., 'openai-codex', 'github-copilot')"), ): """Authenticate with an OAuth provider.""" - console.print(f"{__logo__} OAuth Login - {provider}\n") + from nanobot.providers.registry import PROVIDERS - if provider == "openai-codex": - try: - from oauth_cli_kit import get_token, login_oauth_interactive - token = None - try: - token = get_token() - except Exception: - token = None - if not (token and token.access): - console.print("[cyan]No valid token found. Starting interactive OAuth login...[/cyan]") - console.print("A browser window may open for you to authenticate.\n") - token = login_oauth_interactive( - print_fn=lambda s: console.print(s), - prompt_fn=lambda s: typer.prompt(s), - ) - if not (token and token.access): - console.print("[red]✗ Authentication failed[/red]") - raise typer.Exit(1) - console.print("[green]✓ Successfully authenticated with OpenAI Codex![/green]") - console.print(f"[dim]Account ID: {token.account_id}[/dim]") - except ImportError: - console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]") - raise typer.Exit(1) - except Exception as e: - console.print(f"[red]Authentication error: {e}[/red]") - raise typer.Exit(1) - else: + # Normalize: "github-copilot" → "github_copilot" + provider_key = provider.replace("-", "_") + + # Validate against the registry — only OAuth providers support login + spec = None + for s in PROVIDERS: + if s.name == provider_key and s.is_oauth: + spec = s + break + if not spec: + oauth_names = [s.name.replace("_", "-") for s in PROVIDERS if s.is_oauth] console.print(f"[red]Unknown OAuth provider: {provider}[/red]") - console.print("[yellow]Supported providers: openai-codex[/yellow]") + console.print(f"[yellow]Supported providers: {', '.join(oauth_names)}[/yellow]") + raise typer.Exit(1) + + console.print(f"{__logo__} OAuth Login - {spec.display_name}\n") + + if spec.name == "openai_codex": + _login_openai_codex() + elif spec.name == "github_copilot": + _login_github_copilot() + + +def _login_openai_codex() -> None: + """Authenticate with OpenAI Codex via oauth_cli_kit.""" + try: + from oauth_cli_kit import get_token, login_oauth_interactive + token = None + try: + token = get_token() + except Exception: + token = None + if not (token and token.access): + console.print("[cyan]No valid token found. Starting interactive OAuth login...[/cyan]") + console.print("A browser window may open for you to authenticate.\n") + token = login_oauth_interactive( + print_fn=lambda s: console.print(s), + prompt_fn=lambda s: typer.prompt(s), + ) + if not (token and token.access): + console.print("[red]✗ Authentication failed[/red]") + raise typer.Exit(1) + console.print("[green]✓ Successfully authenticated with OpenAI Codex![/green]") + console.print(f"[dim]Account ID: {token.account_id}[/dim]") + except ImportError: + console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]") + raise typer.Exit(1) + except Exception as e: + console.print(f"[red]Authentication error: {e}[/red]") + raise typer.Exit(1) + + +def _login_github_copilot() -> None: + """Authenticate with GitHub Copilot via LiteLLM's device flow. + + LiteLLM handles the full OAuth device flow (device code → poll → token + storage) internally when a github_copilot/ model is first called. + We trigger that flow by sending a minimal completion request. + """ + import asyncio + + console.print("[cyan]Starting GitHub Copilot device flow via LiteLLM...[/cyan]") + console.print("You will be prompted to visit a URL and enter a device code.\n") + + async def _trigger_device_flow() -> None: + from litellm import acompletion + await acompletion( + model="github_copilot/gpt-4o", + messages=[{"role": "user", "content": "hi"}], + max_tokens=1, + ) + + try: + asyncio.run(_trigger_device_flow()) + console.print("\n[green]✓ Successfully authenticated with GitHub Copilot![/green]") + except Exception as e: + error_msg = str(e) + # A successful device flow still returns a valid response; + # any exception here means the flow genuinely failed. + console.print(f"[red]Authentication error: {error_msg}[/red]") + console.print("[yellow]Ensure you have a GitHub Copilot subscription.[/yellow]") raise typer.Exit(1) diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 43dfbb5..8cc4e35 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -38,9 +38,6 @@ class LiteLLMProvider(LLMProvider): # api_key / api_base are fallback for auto-detection. self._gateway = find_gateway(provider_name, api_key, api_base) - # Detect GitHub Copilot (uses OAuth device flow, no API key) - self.is_github_copilot = "github_copilot" in default_model - # Configure environment variables if api_key: self._setup_env(api_key, api_base, default_model) @@ -79,10 +76,6 @@ class LiteLLMProvider(LLMProvider): def _resolve_model(self, model: str) -> str: """Resolve model name by applying provider/gateway prefixes.""" - # GitHub Copilot models pass through directly - if self.is_github_copilot: - return model - if self._gateway: # Gateway mode: apply gateway prefix, skip provider-specific prefixes prefix = self._gateway.litellm_prefix From e2a0d639099372e4da89173a9a3ce5c76d3264ba Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 18 Feb 2026 02:39:15 +0000 Subject: [PATCH 14/36] feat: add custom provider with direct openai-compatible support --- README.md | 6 ++-- nanobot/cli/commands.py | 13 ++++++-- nanobot/providers/custom_provider.py | 47 ++++++++++++++++++++++++++++ nanobot/providers/registry.py | 15 +++++---- 4 files changed, 68 insertions(+), 13 deletions(-) create mode 100644 nanobot/providers/custom_provider.py diff --git a/README.md b/README.md index 325aa64..30210a7 100644 --- a/README.md +++ b/README.md @@ -574,7 +574,7 @@ Config file: `~/.nanobot/config.json` | Provider | Purpose | Get API Key | |----------|---------|-------------| -| `custom` | Any OpenAI-compatible endpoint | — | +| `custom` | Any OpenAI-compatible endpoint (direct, no LiteLLM) | — | | `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | | `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | | `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | @@ -623,7 +623,7 @@ nanobot agent -m "Hello!"
Custom Provider (Any OpenAI-compatible API) -If your provider is not listed above but exposes an **OpenAI-compatible API** (e.g. Together AI, Fireworks, Azure OpenAI, self-hosted endpoints), use the `custom` provider: +Connects directly to any OpenAI-compatible endpoint — LM Studio, llama.cpp, Together AI, Fireworks, Azure OpenAI, or any self-hosted server. Bypasses LiteLLM; model name is passed as-is. ```json { @@ -641,7 +641,7 @@ If your provider is not listed above but exposes an **OpenAI-compatible API** (e } ``` -> The `custom` provider routes through LiteLLM's OpenAI-compatible path. It works with any endpoint that follows the OpenAI chat completions API format. The model name is passed directly to the endpoint without any prefix. +> For local servers that don't require a key, set `apiKey` to any non-empty string (e.g. `"no-key"`).
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 5280d0f..6b245bf 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -280,18 +280,27 @@ This file stores important information that should persist across sessions. def _make_provider(config: Config): - """Create LiteLLMProvider from config. Exits if no API key found.""" + """Create the appropriate LLM provider from config.""" from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.openai_codex_provider import OpenAICodexProvider + from nanobot.providers.custom_provider import CustomProvider model = config.agents.defaults.model provider_name = config.get_provider_name(model) p = config.get_provider(model) - # OpenAI Codex (OAuth): don't route via LiteLLM; use the dedicated implementation. + # OpenAI Codex (OAuth) if provider_name == "openai_codex" or model.startswith("openai-codex/"): return OpenAICodexProvider(default_model=model) + # Custom: direct OpenAI-compatible endpoint, bypasses LiteLLM + if provider_name == "custom": + return 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, + ) + 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): diff --git a/nanobot/providers/custom_provider.py b/nanobot/providers/custom_provider.py new file mode 100644 index 0000000..f190ccf --- /dev/null +++ b/nanobot/providers/custom_provider.py @@ -0,0 +1,47 @@ +"""Direct OpenAI-compatible provider — bypasses LiteLLM.""" + +from __future__ import annotations + +from typing import Any + +import json_repair +from openai import AsyncOpenAI + +from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest + + +class CustomProvider(LLMProvider): + + def __init__(self, api_key: str = "no-key", api_base: str = "http://localhost:8000/v1", default_model: str = "default"): + super().__init__(api_key, api_base) + self.default_model = default_model + self._client = AsyncOpenAI(api_key=api_key, base_url=api_base) + + async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None, + model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7) -> LLMResponse: + kwargs: dict[str, Any] = {"model": model or self.default_model, "messages": messages, + "max_tokens": max(1, max_tokens), "temperature": temperature} + if tools: + kwargs.update(tools=tools, tool_choice="auto") + try: + return self._parse(await self._client.chat.completions.create(**kwargs)) + except Exception as e: + return LLMResponse(content=f"Error: {e}", finish_reason="error") + + def _parse(self, response: Any) -> LLMResponse: + choice = response.choices[0] + msg = choice.message + tool_calls = [ + ToolCallRequest(id=tc.id, name=tc.function.name, + arguments=json_repair.loads(tc.function.arguments) if isinstance(tc.function.arguments, str) else tc.function.arguments) + for tc in (msg.tool_calls or []) + ] + u = response.usage + return LLMResponse( + content=msg.content, tool_calls=tool_calls, finish_reason=choice.finish_reason or "stop", + usage={"prompt_tokens": u.prompt_tokens, "completion_tokens": u.completion_tokens, "total_tokens": u.total_tokens} if u else {}, + reasoning_content=getattr(msg, "reasoning_content", None), + ) + + def get_default_model(self) -> str: + return self.default_model diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 1e760d6..7d951fa 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -54,6 +54,9 @@ class ProviderSpec: # OAuth-based providers (e.g., OpenAI Codex) don't use API keys is_oauth: bool = False # if True, uses OAuth flow instead of API key + # Direct providers bypass LiteLLM entirely (e.g., CustomProvider) + is_direct: bool = False + @property def label(self) -> str: return self.display_name or self.name.title() @@ -65,18 +68,14 @@ class ProviderSpec: PROVIDERS: tuple[ProviderSpec, ...] = ( - # === Custom (user-provided OpenAI-compatible endpoint) ================= - # No auto-detection — only activates when user explicitly configures "custom". - + # === Custom (direct OpenAI-compatible endpoint, bypasses LiteLLM) ====== ProviderSpec( name="custom", keywords=(), - env_key="OPENAI_API_KEY", + env_key="", display_name="Custom", - litellm_prefix="openai", - skip_prefixes=("openai/",), - is_gateway=True, - strip_model_prefix=True, + litellm_prefix="", + is_direct=True, ), # === Gateways (detected by api_key / api_base, not model name) ========= From d54831a35f25c09450054a451fef3f6a4cda7941 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 18 Feb 2026 03:09:09 +0000 Subject: [PATCH 15/36] feat: add github copilot oauth login and improve provider status display --- README.md | 2 +- nanobot/cli/commands.py | 96 +++++++++++++++++----------------------- nanobot/config/schema.py | 4 +- 3 files changed, 42 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 30210a7..03789de 100644 --- a/README.md +++ b/README.md @@ -588,7 +588,7 @@ Config file: `~/.nanobot/config.json` | `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) | | `vllm` | LLM (local, any OpenAI-compatible server) | — | | `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex` | -| `github_copilot` | LLM (GitHub Copilot, OAuth) | Requires [GitHub Copilot](https://github.com/features/copilot) subscription | +| `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` |
OpenAI Codex (OAuth) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 5efc297..8e17139 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -922,48 +922,50 @@ provider_app = typer.Typer(help="Manage providers") app.add_typer(provider_app, name="provider") +_LOGIN_HANDLERS: dict[str, callable] = {} + + +def _register_login(name: str): + def decorator(fn): + _LOGIN_HANDLERS[name] = fn + return fn + return decorator + + @provider_app.command("login") def provider_login( - provider: str = typer.Argument(..., help="OAuth provider to authenticate with (e.g., 'openai-codex', 'github-copilot')"), + provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot')"), ): """Authenticate with an OAuth provider.""" from nanobot.providers.registry import PROVIDERS - # Normalize: "github-copilot" → "github_copilot" - provider_key = provider.replace("-", "_") - - # Validate against the registry — only OAuth providers support login - spec = None - for s in PROVIDERS: - if s.name == provider_key and s.is_oauth: - spec = s - break + key = provider.replace("-", "_") + spec = next((s for s in PROVIDERS if s.name == key and s.is_oauth), None) if not spec: - oauth_names = [s.name.replace("_", "-") for s in PROVIDERS if s.is_oauth] - console.print(f"[red]Unknown OAuth provider: {provider}[/red]") - console.print(f"[yellow]Supported providers: {', '.join(oauth_names)}[/yellow]") + names = ", ".join(s.name.replace("_", "-") for s in PROVIDERS if s.is_oauth) + console.print(f"[red]Unknown OAuth provider: {provider}[/red] Supported: {names}") raise typer.Exit(1) - console.print(f"{__logo__} OAuth Login - {spec.display_name}\n") + handler = _LOGIN_HANDLERS.get(spec.name) + if not handler: + console.print(f"[red]Login not implemented for {spec.label}[/red]") + raise typer.Exit(1) - if spec.name == "openai_codex": - _login_openai_codex() - elif spec.name == "github_copilot": - _login_github_copilot() + console.print(f"{__logo__} OAuth Login - {spec.label}\n") + handler() +@_register_login("openai_codex") def _login_openai_codex() -> None: - """Authenticate with OpenAI Codex via oauth_cli_kit.""" try: from oauth_cli_kit import get_token, login_oauth_interactive token = None try: token = get_token() except Exception: - token = None + pass if not (token and token.access): - console.print("[cyan]No valid token found. Starting interactive OAuth login...[/cyan]") - console.print("A browser window may open for you to authenticate.\n") + console.print("[cyan]Starting interactive OAuth login...[/cyan]\n") token = login_oauth_interactive( print_fn=lambda s: console.print(s), prompt_fn=lambda s: typer.prompt(s), @@ -971,47 +973,29 @@ def _login_openai_codex() -> None: if not (token and token.access): console.print("[red]✗ Authentication failed[/red]") raise typer.Exit(1) - console.print("[green]✓ Successfully authenticated with OpenAI Codex![/green]") - console.print(f"[dim]Account ID: {token.account_id}[/dim]") + console.print(f"[green]✓ Authenticated with OpenAI Codex[/green] [dim]{token.account_id}[/dim]") except ImportError: console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]") raise typer.Exit(1) + + +@_register_login("github_copilot") +def _login_github_copilot() -> None: + import asyncio + + console.print("[cyan]Starting GitHub Copilot device flow...[/cyan]\n") + + async def _trigger(): + from litellm import acompletion + await acompletion(model="github_copilot/gpt-4o", messages=[{"role": "user", "content": "hi"}], max_tokens=1) + + try: + asyncio.run(_trigger()) + console.print("[green]✓ Authenticated with GitHub Copilot[/green]") except Exception as e: console.print(f"[red]Authentication error: {e}[/red]") raise typer.Exit(1) -def _login_github_copilot() -> None: - """Authenticate with GitHub Copilot via LiteLLM's device flow. - - LiteLLM handles the full OAuth device flow (device code → poll → token - storage) internally when a github_copilot/ model is first called. - We trigger that flow by sending a minimal completion request. - """ - import asyncio - - console.print("[cyan]Starting GitHub Copilot device flow via LiteLLM...[/cyan]") - console.print("You will be prompted to visit a URL and enter a device code.\n") - - async def _trigger_device_flow() -> None: - from litellm import acompletion - await acompletion( - model="github_copilot/gpt-4o", - messages=[{"role": "user", "content": "hi"}], - max_tokens=1, - ) - - try: - asyncio.run(_trigger_device_flow()) - console.print("\n[green]✓ Successfully authenticated with GitHub Copilot![/green]") - except Exception as e: - error_msg = str(e) - # A successful device flow still returns a valid response; - # any exception here means the flow genuinely failed. - console.print(f"[red]Authentication error: {error_msg}[/red]") - console.print("[yellow]Ensure you have a GitHub Copilot subscription.[/yellow]") - raise typer.Exit(1) - - if __name__ == "__main__": app() diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index fe909ef..3cacbde 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -300,9 +300,7 @@ class Config(BaseSettings): if spec.is_oauth: continue p = getattr(self.providers, spec.name, None) - if p is None: - continue - if p.api_key: + if p and p.api_key: return p, spec.name return None, None From 80a5a8c983de351b62060b9d1bc3d40d1a122228 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 18 Feb 2026 03:52:53 +0000 Subject: [PATCH 16/36] feat: add siliconflow provider support --- README.md | 1 + nanobot/providers/registry.py | 9 +++------ 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 03789de..dfe799a 100644 --- a/README.md +++ b/README.md @@ -583,6 +583,7 @@ Config file: `~/.nanobot/config.json` | `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | | `minimax` | LLM (MiniMax direct) | [platform.minimax.io](https://platform.minimax.io) | | `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) | +| `siliconflow` | LLM (SiliconFlow/硅基流动, API gateway) | [siliconflow.cn](https://siliconflow.cn) | | `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/providers/registry.py b/nanobot/providers/registry.py index d267069..49b735c 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -119,16 +119,13 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( model_overrides=(), ), - # SiliconFlow (硅基流动): OpenAI-compatible gateway hosting multiple models. - # strip_model_prefix=False: SiliconFlow model names include org prefix - # (e.g. "Qwen/Qwen2.5-14B-Instruct", "deepseek-ai/DeepSeek-V3") - # which is part of the model ID and must NOT be stripped. + # SiliconFlow (硅基流动): OpenAI-compatible gateway, model names keep org prefix ProviderSpec( name="siliconflow", keywords=("siliconflow",), - env_key="OPENAI_API_KEY", # OpenAI-compatible + env_key="OPENAI_API_KEY", display_name="SiliconFlow", - litellm_prefix="openai", # → openai/{model} + litellm_prefix="openai", skip_prefixes=(), env_extras=(), is_gateway=True, From 27a131830f31ae8560aa4f0f44fad577d6e940c4 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 18 Feb 2026 05:09:57 +0000 Subject: [PATCH 17/36] refine: migrate legacy sessions on load and simplify get_history --- nanobot/session/manager.py | 39 +++++++++++++------------------------- 1 file changed, 13 insertions(+), 26 deletions(-) diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index e2d8e5c..752fce4 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -42,30 +42,15 @@ class Session: self.updated_at = datetime.now() def get_history(self, max_messages: int = 500) -> list[dict[str, Any]]: - """ - Get recent messages in LLM format. - - Preserves tool metadata for replay/debugging fidelity. - """ - history: list[dict[str, Any]] = [] - for msg in self.messages[-max_messages:]: - llm_msg: dict[str, Any] = { - "role": msg["role"], - "content": msg.get("content", ""), - } - - if msg["role"] == "assistant" and "tool_calls" in msg: - llm_msg["tool_calls"] = msg["tool_calls"] - - if msg["role"] == "tool": - if "tool_call_id" in msg: - llm_msg["tool_call_id"] = msg["tool_call_id"] - if "name" in msg: - llm_msg["name"] = msg["name"] - - history.append(llm_msg) - - return history + """Get recent messages in LLM format, preserving tool metadata.""" + out: list[dict[str, Any]] = [] + for m in self.messages[-max_messages:]: + entry: dict[str, Any] = {"role": m["role"], "content": m.get("content", "")} + for k in ("tool_calls", "tool_call_id", "name"): + if k in m: + entry[k] = m[k] + out.append(entry) + return out def clear(self) -> None: """Clear all messages and reset session to initial state.""" @@ -93,7 +78,7 @@ class SessionManager: return self.sessions_dir / f"{safe_key}.jsonl" def _get_legacy_session_path(self, key: str) -> Path: - """Get the legacy global session path for backward compatibility.""" + """Legacy global session path (~/.nanobot/sessions/).""" safe_key = safe_filename(key.replace(":", "_")) return self.legacy_sessions_dir / f"{safe_key}.jsonl" @@ -123,7 +108,9 @@ class SessionManager: if not path.exists(): legacy_path = self._get_legacy_session_path(key) if legacy_path.exists(): - path = legacy_path + import shutil + shutil.move(str(legacy_path), str(path)) + logger.info(f"Migrated session {key} from legacy path") if not path.exists(): return None From 715b2db24b312cd81d6601651cc17f7a523d722b Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 18 Feb 2026 14:23:51 +0000 Subject: [PATCH 18/36] feat: stream intermediate progress to user during tool execution --- README.md | 2 +- nanobot/agent/context.py | 2 +- nanobot/agent/loop.py | 57 +++++++++++++++++++++++++++++++++++----- nanobot/cli/commands.py | 7 +++-- 4 files changed, 57 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index dfe799a..fcbc878 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines. -📏 Real-time line count: **3,696 lines** (run `bash core_agent_lines.sh` to verify anytime) +📏 Real-time line count: **3,761 lines** (run `bash core_agent_lines.sh` to verify anytime) ## 📢 News diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index cfd6318..876d43d 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -105,7 +105,7 @@ IMPORTANT: When responding to direct questions or conversations, reply directly Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp). For normal conversation, just respond with text - do not call the message tool. -Always be helpful, accurate, and concise. When using tools, think step by step: what you know, what you need, and why you chose this tool. +Always be helpful, accurate, and concise. Before calling tools, briefly tell the user what you're about to do (one short sentence in the user's language). When remembering something important, write to {workspace_path}/memory/MEMORY.md To recall past events, grep {workspace_path}/memory/HISTORY.md""" diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 6342f56..e5a5183 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -5,7 +5,8 @@ from contextlib import AsyncExitStack import json import json_repair from pathlib import Path -from typing import Any +import re +from typing import Any, Awaitable, Callable from loguru import logger @@ -146,12 +147,34 @@ class AgentLoop: if isinstance(cron_tool, CronTool): cron_tool.set_context(channel, chat_id) - async def _run_agent_loop(self, initial_messages: list[dict]) -> tuple[str | None, list[str]]: + @staticmethod + def _strip_think(text: str | None) -> str | None: + """Remove blocks that some models embed in content.""" + if not text: + return None + return re.sub(r"[\s\S]*?", "", text).strip() or None + + @staticmethod + def _tool_hint(tool_calls: list) -> str: + """Format tool calls as concise hint, e.g. 'web_search("query")'.""" + def _fmt(tc): + val = next(iter(tc.arguments.values()), None) if tc.arguments else None + if not isinstance(val, str): + return tc.name + return f'{tc.name}("{val[:40]}…")' if len(val) > 40 else f'{tc.name}("{val}")' + return ", ".join(_fmt(tc) for tc in tool_calls) + + async def _run_agent_loop( + self, + initial_messages: list[dict], + on_progress: Callable[[str], Awaitable[None]] | None = None, + ) -> tuple[str | None, list[str]]: """ Run the agent iteration loop. Args: initial_messages: Starting messages for the LLM conversation. + on_progress: Optional callback to push intermediate content to the user. Returns: Tuple of (final_content, list_of_tools_used). @@ -173,6 +196,10 @@ class AgentLoop: ) if response.has_tool_calls: + if on_progress: + clean = self._strip_think(response.content) + await on_progress(clean or self._tool_hint(response.tool_calls)) + tool_call_dicts = [ { "id": tc.id, @@ -197,9 +224,8 @@ class AgentLoop: messages = self.context.add_tool_result( messages, tool_call.id, tool_call.name, result ) - messages.append({"role": "user", "content": "Reflect on the results and decide next steps."}) else: - final_content = response.content + final_content = self._strip_think(response.content) break return final_content, tools_used @@ -244,13 +270,19 @@ class AgentLoop: self._running = False logger.info("Agent loop stopping") - async def _process_message(self, msg: InboundMessage, session_key: str | None = None) -> OutboundMessage | None: + async def _process_message( + self, + msg: InboundMessage, + session_key: str | None = None, + on_progress: Callable[[str], Awaitable[None]] | None = None, + ) -> OutboundMessage | None: """ Process a single inbound message. Args: msg: The inbound message to process. session_key: Override session key (used by process_direct). + on_progress: Optional callback for intermediate output (defaults to bus publish). Returns: The response message, or None if no response needed. @@ -297,7 +329,16 @@ class AgentLoop: channel=msg.channel, chat_id=msg.chat_id, ) - final_content, tools_used = await self._run_agent_loop(initial_messages) + + async def _bus_progress(content: str) -> None: + await self.bus.publish_outbound(OutboundMessage( + channel=msg.channel, chat_id=msg.chat_id, content=content, + metadata=msg.metadata or {}, + )) + + final_content, tools_used = await self._run_agent_loop( + initial_messages, on_progress=on_progress or _bus_progress, + ) if final_content is None: final_content = "I've completed processing but have no response to give." @@ -451,6 +492,7 @@ Respond with ONLY valid JSON, no markdown fences.""" session_key: str = "cli:direct", channel: str = "cli", chat_id: str = "direct", + on_progress: Callable[[str], Awaitable[None]] | None = None, ) -> str: """ Process a message directly (for CLI or cron usage). @@ -460,6 +502,7 @@ Respond with ONLY valid JSON, no markdown fences.""" session_key: Session identifier (overrides channel:chat_id for session lookup). channel: Source channel (for tool context routing). chat_id: Source chat ID (for tool context routing). + on_progress: Optional callback for intermediate output. Returns: The agent's response. @@ -472,5 +515,5 @@ Respond with ONLY valid JSON, no markdown fences.""" content=content ) - response = await self._process_message(msg, session_key=session_key) + response = await self._process_message(msg, session_key=session_key, on_progress=on_progress) return response.content if response else "" diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 8e17139..2f4ba7b 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -494,11 +494,14 @@ def agent( # Animated spinner is safe to use with prompt_toolkit input handling return console.status("[dim]nanobot is thinking...[/dim]", spinner="dots") + async def _cli_progress(content: str) -> None: + console.print(f" [dim]↳ {content}[/dim]") + if message: # Single message mode async def run_once(): with _thinking_ctx(): - response = await agent_loop.process_direct(message, session_id) + response = await agent_loop.process_direct(message, session_id, on_progress=_cli_progress) _print_agent_response(response, render_markdown=markdown) await agent_loop.close_mcp() @@ -531,7 +534,7 @@ def agent( break with _thinking_ctx(): - response = await agent_loop.process_direct(user_input, session_id) + response = await agent_loop.process_direct(user_input, session_id, on_progress=_cli_progress) _print_agent_response(response, render_markdown=markdown) except KeyboardInterrupt: _restore_terminal() From b14d4711c0cbff34d030b47018bf1bcbf5e33f26 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 18 Feb 2026 14:31:26 +0000 Subject: [PATCH 19/36] release: v0.1.4 --- nanobot/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nanobot/__init__.py b/nanobot/__init__.py index ee0445b..a68777c 100644 --- a/nanobot/__init__.py +++ b/nanobot/__init__.py @@ -2,5 +2,5 @@ nanobot - A lightweight AI agent framework """ -__version__ = "0.1.0" +__version__ = "0.1.4" __logo__ = "🐈" diff --git a/pyproject.toml b/pyproject.toml index 6261653..bbd6feb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nanobot-ai" -version = "0.1.3.post7" +version = "0.1.4" description = "A lightweight personal AI assistant framework" requires-python = ">=3.11" license = {text = "MIT"} From 1f1f5b2d275c177e457efc0cafed650f2f707bf7 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 18 Feb 2026 14:41:13 +0000 Subject: [PATCH 20/36] docs: update v0.1.4 release news --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index fcbc878..7fad9ce 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ ## 📢 News +- **2026-02-17** 🎉 Released v0.1.4 — MCP support, progress streaming, new providers, and multiple channel improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4) for details. - **2026-02-16** 🦞 nanobot now integrates a [ClawHub](https://clawhub.ai) skill — search and install public agent skills. - **2026-02-15** 🔑 nanobot now supports OpenAI Codex provider with OAuth login support. - **2026-02-14** 🔌 nanobot now supports MCP! See [MCP section](#mcp-model-context-protocol) for details. @@ -29,6 +30,10 @@ - **2026-02-10** 🎉 Released v0.1.3.post6 with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431). - **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms! - **2026-02-08** 🔧 Refactored Providers—adding a new LLM provider now takes just 2 simple steps! Check [here](#providers). + +
+Earlier news + - **2026-02-07** 🚀 Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details. - **2026-02-06** ✨ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening! - **2026-02-05** ✨ Added Feishu channel, DeepSeek provider, and enhanced scheduled tasks support! @@ -36,6 +41,8 @@ - **2026-02-03** ⚡ Integrated vLLM for local LLM support and improved natural language task scheduling! - **2026-02-02** 🎉 nanobot officially launched! Welcome to try 🐈 nanobot! +
+ ## Key Features of nanobot: 🪶 **Ultra-Lightweight**: Just ~4,000 lines of core agent code — 99% smaller than Clawdbot. From 8de36d398fb017af42ee831c653a9e8c5cd90783 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Wed, 18 Feb 2026 23:09:55 +0800 Subject: [PATCH 21/36] docs: update news about release information --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7fad9ce..a474367 100644 --- a/README.md +++ b/README.md @@ -20,24 +20,24 @@ ## 📢 News -- **2026-02-17** 🎉 Released v0.1.4 — MCP support, progress streaming, new providers, and multiple channel improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4) for details. +- **2026-02-17** 🎉 Released **v0.1.4** — MCP support, progress streaming, new providers, and multiple channel improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4) for details. - **2026-02-16** 🦞 nanobot now integrates a [ClawHub](https://clawhub.ai) skill — search and install public agent skills. - **2026-02-15** 🔑 nanobot now supports OpenAI Codex provider with OAuth login support. - **2026-02-14** 🔌 nanobot now supports MCP! See [MCP section](#mcp-model-context-protocol) for details. -- **2026-02-13** 🎉 Released v0.1.3.post7 — includes security hardening and multiple improvements. All users are recommended to upgrade to the latest version. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details. +- **2026-02-13** 🎉 Released **v0.1.3.post7** — includes security hardening and multiple improvements. **Please upgrade to the latest version to address security issues**. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details. - **2026-02-12** 🧠 Redesigned memory system — Less code, more reliable. Join the [discussion](https://github.com/HKUDS/nanobot/discussions/566) about it! - **2026-02-11** ✨ Enhanced CLI experience and added MiniMax support! -- **2026-02-10** 🎉 Released v0.1.3.post6 with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431). +- **2026-02-10** 🎉 Released **v0.1.3.post6** with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431). - **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms! - **2026-02-08** 🔧 Refactored Providers—adding a new LLM provider now takes just 2 simple steps! Check [here](#providers).
Earlier news -- **2026-02-07** 🚀 Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details. +- **2026-02-07** 🚀 Released **v0.1.3.post5** with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details. - **2026-02-06** ✨ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening! - **2026-02-05** ✨ Added Feishu channel, DeepSeek provider, and enhanced scheduled tasks support! -- **2026-02-04** 🚀 Released v0.1.3.post4 with multi-provider & Docker support! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details. +- **2026-02-04** 🚀 Released **v0.1.3.post4** with multi-provider & Docker support! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details. - **2026-02-03** ⚡ Integrated vLLM for local LLM support and improved natural language task scheduling! - **2026-02-02** 🎉 nanobot officially launched! Welcome to try 🐈 nanobot! From 536ed60a05fcd1510d17e61d1e30a6a926f5d319 Mon Sep 17 00:00:00 2001 From: ruby childs Date: Wed, 18 Feb 2026 16:39:06 -0500 Subject: [PATCH 22/36] Fix safety guard false positive on 'format' in URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deny pattern `\b(format|mkfs|diskpart)\b` incorrectly blocked commands containing "format" inside URLs (e.g. `curl https://wttr.in?format=3`) because `\b` fires at the boundary between `?` (non-word) and `f` (word). Split into two patterns: - `(?:^|[;&|]\s*)format\b` — only matches `format` as a standalone command (start of line or after shell operators) - `\b(mkfs|diskpart)\b` — kept as-is (unique enough to not false-positive) Co-Authored-By: Claude Opus 4.6 --- nanobot/agent/tools/shell.py | 3 +- tests/test_exec_curl.py | 97 ++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 tests/test_exec_curl.py diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index 18eff64..9c6f1f6 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -26,7 +26,8 @@ class ExecTool(Tool): r"\brm\s+-[rf]{1,2}\b", # rm -r, rm -rf, rm -fr r"\bdel\s+/[fq]\b", # del /f, del /q r"\brmdir\s+/s\b", # rmdir /s - r"\b(format|mkfs|diskpart)\b", # disk operations + r"(?:^|[;&|]\s*)format\b", # format (as standalone command only) + r"\b(mkfs|diskpart)\b", # disk operations r"\bdd\s+if=", # dd r">\s*/dev/sd", # write to disk r"\b(shutdown|reboot|poweroff)\b", # system power diff --git a/tests/test_exec_curl.py b/tests/test_exec_curl.py new file mode 100644 index 0000000..2f53fcf --- /dev/null +++ b/tests/test_exec_curl.py @@ -0,0 +1,97 @@ +r"""Tests for ExecTool safety guard — format pattern false positive. + +The old deny pattern `\b(format|mkfs|diskpart)\b` matched "format" inside +URLs (e.g. `curl https://wttr.in?format=3`) because `?` is a non-word +character, so `\b` fires between `?` and `f`. + +The fix splits the pattern: + - `(?:^|[;&|]\s*)format\b` — only matches `format` as a standalone command + - `\b(mkfs|diskpart)\b` — kept as-is (unique enough to not false-positive) +""" + +import re + +import pytest + +from nanobot.agent.tools.shell import ExecTool + + +# --- Guard regression: "format" in URLs must not be blocked --- + + +@pytest.mark.asyncio +async def test_curl_with_format_in_url_not_blocked(): + """curl with ?format= in URL should NOT be blocked by the guard.""" + tool = ExecTool(working_dir="/tmp") + result = await tool.execute( + command="curl -s 'https://wttr.in/Brooklyn?format=3'" + ) + assert "blocked by safety guard" not in result + + +@pytest.mark.asyncio +async def test_curl_with_format_in_post_body_not_blocked(): + """curl with 'format=json' in POST body should NOT be blocked.""" + tool = ExecTool(working_dir="/tmp") + result = await tool.execute( + command="curl -s -d 'format=json' https://httpbin.org/post" + ) + assert "blocked by safety guard" not in result + + +@pytest.mark.asyncio +async def test_curl_without_format_not_blocked(): + """Plain curl commands should pass the guard.""" + tool = ExecTool(working_dir="/tmp") + result = await tool.execute(command="curl -s https://httpbin.org/get") + assert "blocked by safety guard" not in result + + +# --- The guard still blocks actual format commands --- + + +@pytest.mark.asyncio +async def test_guard_blocks_standalone_format_command(): + """'format c:' as a standalone command must be blocked.""" + tool = ExecTool(working_dir="/tmp") + result = await tool.execute(command="format c:") + assert "blocked by safety guard" in result + + +@pytest.mark.asyncio +async def test_guard_blocks_format_after_semicolon(): + """'echo hi; format c:' must be blocked.""" + tool = ExecTool(working_dir="/tmp") + result = await tool.execute(command="echo hi; format c:") + assert "blocked by safety guard" in result + + +@pytest.mark.asyncio +async def test_guard_blocks_format_after_pipe(): + """'echo hi | format' must be blocked.""" + tool = ExecTool(working_dir="/tmp") + result = await tool.execute(command="echo hi | format") + assert "blocked by safety guard" in result + + +# --- Regex unit tests (no I/O) --- + + +def test_format_pattern_blocks_disk_commands(): + """The tightened pattern still catches actual format commands.""" + pattern = r"(?:^|[;&|]\s*)format\b" + + assert re.search(pattern, "format c:") + assert re.search(pattern, "echo hi; format c:") + assert re.search(pattern, "echo hi | format") + assert re.search(pattern, "cmd && format d:") + + +def test_format_pattern_allows_urls_and_flags(): + """The tightened pattern does NOT match format inside URLs or flags.""" + pattern = r"(?:^|[;&|]\s*)format\b" + + assert not re.search(pattern, "curl https://wttr.in?format=3") + assert not re.search(pattern, "echo --output-format=json") + assert not re.search(pattern, "curl -d 'format=json' https://api.example.com") + assert not re.search(pattern, "python -c 'print(\"{:format}\".format(1))'") From c7b5dd93502a829da18e2a7ef2253ae3298f2f28 Mon Sep 17 00:00:00 2001 From: chtangwin Date: Tue, 10 Feb 2026 17:31:45 +0800 Subject: [PATCH 23/36] Fix: Ensure UTF-8 encoding for all file operations --- nanobot/agent/loop.py | 3 ++- nanobot/agent/subagent.py | 4 ++-- nanobot/config/loader.py | 2 +- nanobot/cron/service.py | 4 ++-- nanobot/session/manager.py | 11 ++++++----- 5 files changed, 13 insertions(+), 11 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index e5a5183..1cd5730 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -206,7 +206,7 @@ class AgentLoop: "type": "function", "function": { "name": tc.name, - "arguments": json.dumps(tc.arguments) + "arguments": json.dumps(tc.arguments, ensure_ascii=False) } } for tc in response.tool_calls @@ -388,6 +388,7 @@ class AgentLoop: ) final_content, _ = await self._run_agent_loop(initial_messages) + if final_content is None: final_content = "Background task completed." diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 203836a..ffefc08 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -146,7 +146,7 @@ class SubagentManager: "type": "function", "function": { "name": tc.name, - "arguments": json.dumps(tc.arguments), + "arguments": json.dumps(tc.arguments, ensure_ascii=False), }, } for tc in response.tool_calls @@ -159,7 +159,7 @@ class SubagentManager: # Execute tools for tool_call in response.tool_calls: - args_str = json.dumps(tool_call.arguments) + args_str = json.dumps(tool_call.arguments, ensure_ascii=False) logger.debug(f"Subagent [{task_id}] executing: {tool_call.name} with arguments: {args_str}") result = await tools.execute(tool_call.name, tool_call.arguments) messages.append({ diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py index 560c1f5..134e95f 100644 --- a/nanobot/config/loader.py +++ b/nanobot/config/loader.py @@ -31,7 +31,7 @@ def load_config(config_path: Path | None = None) -> Config: if path.exists(): try: - with open(path) as f: + with open(path, encoding="utf-8") as f: data = json.load(f) data = _migrate_config(data) return Config.model_validate(data) diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index 14666e8..3c77452 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -66,7 +66,7 @@ class CronService: if self.store_path.exists(): try: - data = json.loads(self.store_path.read_text()) + data = json.loads(self.store_path.read_text(encoding="utf-8")) jobs = [] for j in data.get("jobs", []): jobs.append(CronJob( @@ -148,7 +148,7 @@ class CronService: ] } - self.store_path.write_text(json.dumps(data, indent=2)) + self.store_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") async def start(self) -> None: """Start the cron service.""" diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 752fce4..42df1b1 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -121,7 +121,7 @@ class SessionManager: created_at = None last_consolidated = 0 - with open(path) as f: + with open(path, encoding="utf-8") as f: for line in f: line = line.strip() if not line: @@ -151,7 +151,7 @@ class SessionManager: """Save a session to disk.""" path = self._get_session_path(session.key) - with open(path, "w") as f: + with open(path, "w", encoding="utf-8") as f: metadata_line = { "_type": "metadata", "created_at": session.created_at.isoformat(), @@ -159,9 +159,10 @@ class SessionManager: "metadata": session.metadata, "last_consolidated": session.last_consolidated } - f.write(json.dumps(metadata_line) + "\n") + f.write(json.dumps(metadata_line, ensure_ascii=False) + "\n") for msg in session.messages: - f.write(json.dumps(msg) + "\n") + f.write(json.dumps(msg, ensure_ascii=False) + "\n") + self._cache[session.key] = session @@ -181,7 +182,7 @@ class SessionManager: for path in self.sessions_dir.glob("*.jsonl"): try: # Read just the metadata line - with open(path) as f: + with open(path, encoding="utf-8") as f: first_line = f.readline().strip() if first_line: data = json.loads(first_line) From a2379a08ac5467e4b3e628a7264427be73759a9f Mon Sep 17 00:00:00 2001 From: chtangwin Date: Wed, 18 Feb 2026 18:37:17 -0800 Subject: [PATCH 24/36] Fix: Ensure UTF-8 encoding and ensure_ascii=False for remaining file/JSON operations --- nanobot/agent/tools/web.py | 8 ++++---- nanobot/channels/dingtalk.py | 2 +- nanobot/cli/commands.py | 6 +++--- nanobot/config/loader.py | 4 ++-- nanobot/heartbeat/service.py | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index 9de1d3c..90cdda8 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -116,7 +116,7 @@ class WebFetchTool(Tool): # Validate URL before fetching is_valid, error_msg = _validate_url(url) if not is_valid: - return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url}) + return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url}, ensure_ascii=False) try: async with httpx.AsyncClient( @@ -131,7 +131,7 @@ class WebFetchTool(Tool): # JSON if "application/json" in ctype: - text, extractor = json.dumps(r.json(), indent=2), "json" + text, extractor = json.dumps(r.json(), indent=2, ensure_ascii=False), "json" # HTML elif "text/html" in ctype or r.text[:256].lower().startswith((" str: """Convert HTML to markdown.""" diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py index 4a8cdd9..6b27af4 100644 --- a/nanobot/channels/dingtalk.py +++ b/nanobot/channels/dingtalk.py @@ -208,7 +208,7 @@ class DingTalkChannel(BaseChannel): "msgParam": json.dumps({ "text": msg.content, "title": "Nanobot Reply", - }), + }, ensure_ascii=False), } if not self._http: diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 2f4ba7b..d879d58 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -243,7 +243,7 @@ Information about the user goes here. for filename, content in templates.items(): file_path = workspace / filename if not file_path.exists(): - file_path.write_text(content) + file_path.write_text(content, encoding="utf-8") console.print(f" [dim]Created {filename}[/dim]") # Create memory directory and MEMORY.md @@ -266,12 +266,12 @@ This file stores important information that should persist across sessions. ## Important Notes (Things to remember) -""") +""", encoding="utf-8") console.print(" [dim]Created memory/MEMORY.md[/dim]") history_file = memory_dir / "HISTORY.md" if not history_file.exists(): - history_file.write_text("") + history_file.write_text("", encoding="utf-8") console.print(" [dim]Created memory/HISTORY.md[/dim]") # Create skills directory for custom user skills diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py index 134e95f..c789efd 100644 --- a/nanobot/config/loader.py +++ b/nanobot/config/loader.py @@ -55,8 +55,8 @@ def save_config(config: Config, config_path: Path | None = None) -> None: data = config.model_dump(by_alias=True) - with open(path, "w") as f: - json.dump(data, f, indent=2) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) def _migrate_config(data: dict) -> dict: diff --git a/nanobot/heartbeat/service.py b/nanobot/heartbeat/service.py index 221ed27..a51e5a0 100644 --- a/nanobot/heartbeat/service.py +++ b/nanobot/heartbeat/service.py @@ -65,7 +65,7 @@ class HeartbeatService: """Read HEARTBEAT.md content.""" if self.heartbeat_file.exists(): try: - return self.heartbeat_file.read_text() + return self.heartbeat_file.read_text(encoding="utf-8") except Exception: return None return None From 124c611426eefe06aeb9a5b3d33338286228bb8a Mon Sep 17 00:00:00 2001 From: chtangwin Date: Wed, 18 Feb 2026 18:46:23 -0800 Subject: [PATCH 25/36] Fix: Add ensure_ascii=False to WhatsApp send payload The send() payload contains user message content (msg.content) which may include non-ASCII characters (e.g. CJK, German umlauts, emoji). The auth frame and Discord heartbeat/identify payloads are left unchanged as they only carry ASCII protocol fields. --- nanobot/channels/whatsapp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index 0cf2dd7..f799347 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -87,7 +87,7 @@ class WhatsAppChannel(BaseChannel): "to": msg.chat_id, "text": msg.content } - await self._ws.send(json.dumps(payload)) + await self._ws.send(json.dumps(payload, ensure_ascii=False)) except Exception as e: logger.error(f"Error sending WhatsApp message: {e}") From 9789307dd691d469881b45ad7e0a7f655bab110d Mon Sep 17 00:00:00 2001 From: PiEgg Date: Thu, 19 Feb 2026 13:30:02 +0800 Subject: [PATCH 26/36] Fix Codex provider routing for GitHub Copilot models --- nanobot/config/schema.py | 25 +++++++++++++- nanobot/providers/litellm_provider.py | 13 +++++++- nanobot/providers/openai_codex_provider.py | 2 +- nanobot/providers/registry.py | 16 ++++++++- tests/test_commands.py | 38 ++++++++++++++++++++++ 5 files changed, 90 insertions(+), 4 deletions(-) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index ce9634c..9558072 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -287,11 +287,34 @@ class Config(BaseSettings): from nanobot.providers.registry import PROVIDERS model_lower = (model or self.agents.defaults.model).lower() + model_normalized = model_lower.replace("-", "_") + model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else "" + normalized_prefix = model_prefix.replace("-", "_") + + def _matches_model_prefix(spec_name: str) -> bool: + if not model_prefix: + return False + return normalized_prefix == spec_name + + def _keyword_matches(keyword: str) -> bool: + keyword_lower = keyword.lower() + return ( + keyword_lower in model_lower + or keyword_lower.replace("-", "_") in model_normalized + ) + + # Explicit provider prefix in model name wins over generic keyword matches. + # This prevents `github-copilot/...codex` from being treated as OpenAI Codex. + for spec in PROVIDERS: + p = getattr(self.providers, spec.name, None) + if p and _matches_model_prefix(spec.name): + if spec.is_oauth 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 in model_lower for kw in spec.keywords): + if p and any(_keyword_matches(kw) for kw in spec.keywords): if spec.is_oauth or p.api_key: return p, spec.name diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 8cc4e35..3fec618 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -88,10 +88,21 @@ class LiteLLMProvider(LLMProvider): # Standard mode: auto-prefix for known providers spec = find_by_model(model) if spec and spec.litellm_prefix: + model = self._canonicalize_explicit_prefix(model, spec.name, spec.litellm_prefix) if not any(model.startswith(s) for s in spec.skip_prefixes): model = f"{spec.litellm_prefix}/{model}" - + return model + + @staticmethod + def _canonicalize_explicit_prefix(model: str, spec_name: str, canonical_prefix: str) -> str: + """Normalize explicit provider prefixes like `github-copilot/...`.""" + if "/" not in model: + return model + prefix, remainder = model.split("/", 1) + if prefix.lower().replace("-", "_") != spec_name: + return model + return f"{canonical_prefix}/{remainder}" def _apply_model_overrides(self, model: str, kwargs: dict[str, Any]) -> None: """Apply model-specific parameter overrides from the registry.""" diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py index 5067438..2336e71 100644 --- a/nanobot/providers/openai_codex_provider.py +++ b/nanobot/providers/openai_codex_provider.py @@ -80,7 +80,7 @@ class OpenAICodexProvider(LLMProvider): def _strip_model_prefix(model: str) -> str: - if model.startswith("openai-codex/"): + if model.startswith("openai-codex/") or model.startswith("openai_codex/"): return model.split("/", 1)[1] return model diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 49b735c..d986fe6 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -384,10 +384,24 @@ def find_by_model(model: str) -> ProviderSpec | None: """Match a standard provider by model-name keyword (case-insensitive). Skips gateways/local — those are matched by api_key/api_base instead.""" model_lower = model.lower() + model_normalized = model_lower.replace("-", "_") + model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else "" + normalized_prefix = model_prefix.replace("-", "_") + + # Prefer explicit provider prefix in model name. for spec in PROVIDERS: if spec.is_gateway or spec.is_local: continue - if any(kw in model_lower for kw in spec.keywords): + if model_prefix and normalized_prefix == spec.name: + return spec + + for spec in PROVIDERS: + if spec.is_gateway or spec.is_local: + continue + if any( + kw in model_lower or kw.replace("-", "_") in model_normalized + for kw in spec.keywords + ): return spec return None diff --git a/tests/test_commands.py b/tests/test_commands.py index f5495fd..044d113 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -6,6 +6,10 @@ import pytest from typer.testing import CliRunner from nanobot.cli.commands import app +from nanobot.config.schema import Config +from nanobot.providers.litellm_provider import LiteLLMProvider +from nanobot.providers.openai_codex_provider import _strip_model_prefix +from nanobot.providers.registry import find_by_model runner = CliRunner() @@ -90,3 +94,37 @@ def test_onboard_existing_workspace_safe_create(mock_paths): assert "Created workspace" not in result.stdout assert "Created AGENTS.md" in result.stdout assert (workspace_dir / "AGENTS.md").exists() + + +def test_config_matches_github_copilot_codex_with_hyphen_prefix(): + config = Config() + config.agents.defaults.model = "github-copilot/gpt-5.3-codex" + + assert config.get_provider_name() == "github_copilot" + + +def test_config_matches_openai_codex_with_hyphen_prefix(): + config = Config() + config.agents.defaults.model = "openai-codex/gpt-5.1-codex" + + assert config.get_provider_name() == "openai_codex" + + +def test_find_by_model_prefers_explicit_prefix_over_generic_codex_keyword(): + spec = find_by_model("github-copilot/gpt-5.3-codex") + + assert spec is not None + assert spec.name == "github_copilot" + + +def test_litellm_provider_canonicalizes_github_copilot_hyphen_prefix(): + provider = LiteLLMProvider(default_model="github-copilot/gpt-5.3-codex") + + resolved = provider._resolve_model("github-copilot/gpt-5.3-codex") + + assert resolved == "github_copilot/gpt-5.3-codex" + + +def test_openai_codex_strip_prefix_supports_hyphen_and_underscore(): + assert _strip_model_prefix("openai-codex/gpt-5.1-codex") == "gpt-5.1-codex" + assert _strip_model_prefix("openai_codex/gpt-5.1-codex") == "gpt-5.1-codex" From d08c02225551b5410e126c91b7224c773b5c9187 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Thu, 19 Feb 2026 16:31:00 +0800 Subject: [PATCH 27/36] feat(feishu): support sending images, audio, and files - Add image upload via im.v1.image.create API - Add file upload via im.v1.file.create API - Support sending images (.png, .jpg, .gif, etc.) as image messages - Support sending audio (.opus) as voice messages - Support sending other files as file messages - Refactor send() to handle media attachments before text content --- nanobot/channels/feishu.py | 179 ++++++++++++++++++++++++++++++------- 1 file changed, 149 insertions(+), 30 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index bc4a2b8..6f2881b 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -17,6 +17,10 @@ from nanobot.config.schema import FeishuConfig try: import lark_oapi as lark from lark_oapi.api.im.v1 import ( + CreateFileRequest, + CreateFileRequestBody, + CreateImageRequest, + CreateImageRequestBody, CreateMessageRequest, CreateMessageRequestBody, CreateMessageReactionRequest, @@ -284,48 +288,163 @@ class FeishuChannel(BaseChannel): return elements or [{"tag": "markdown", "content": content}] + # Image file extensions + _IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"} + + # Audio file extensions (Feishu only supports opus for audio messages) + _AUDIO_EXTS = {".opus"} + + # File type mapping for Feishu file upload API + _FILE_TYPE_MAP = { + ".opus": "opus", ".mp4": "mp4", ".pdf": "pdf", ".doc": "doc", ".docx": "doc", + ".xls": "xls", ".xlsx": "xls", ".ppt": "ppt", ".pptx": "ppt", + } + + def _upload_image_sync(self, file_path: str) -> str | None: + """Upload an image to Feishu and return the image_key.""" + import os + try: + with open(file_path, "rb") as f: + request = CreateImageRequest.builder() \ + .request_body( + CreateImageRequestBody.builder() + .image_type("message") + .image(f) + .build() + ).build() + response = self._client.im.v1.image.create(request) + if response.success(): + image_key = response.data.image_key + logger.debug(f"Uploaded image {os.path.basename(file_path)}: {image_key}") + return image_key + else: + logger.error(f"Failed to upload image: code={response.code}, msg={response.msg}") + return None + except Exception as e: + logger.error(f"Error uploading image {file_path}: {e}") + return None + + def _upload_file_sync(self, file_path: str) -> str | None: + """Upload a file to Feishu and return the file_key.""" + import os + ext = os.path.splitext(file_path)[1].lower() + file_type = self._FILE_TYPE_MAP.get(ext, "stream") + file_name = os.path.basename(file_path) + try: + with open(file_path, "rb") as f: + request = CreateFileRequest.builder() \ + .request_body( + CreateFileRequestBody.builder() + .file_type(file_type) + .file_name(file_name) + .file(f) + .build() + ).build() + response = self._client.im.v1.file.create(request) + if response.success(): + file_key = response.data.file_key + logger.debug(f"Uploaded file {file_name}: {file_key}") + return file_key + else: + logger.error(f"Failed to upload file: code={response.code}, msg={response.msg}") + return None + except Exception as e: + logger.error(f"Error uploading file {file_path}: {e}") + return None + + def _send_message_sync(self, receive_id_type: str, receive_id: str, msg_type: str, content: str) -> bool: + """Send a single message (text/image/file/interactive) synchronously.""" + try: + request = CreateMessageRequest.builder() \ + .receive_id_type(receive_id_type) \ + .request_body( + CreateMessageRequestBody.builder() + .receive_id(receive_id) + .msg_type(msg_type) + .content(content) + .build() + ).build() + response = self._client.im.v1.message.create(request) + if not response.success(): + logger.error( + f"Failed to send Feishu {msg_type} message: code={response.code}, " + f"msg={response.msg}, log_id={response.get_log_id()}" + ) + return False + logger.debug(f"Feishu {msg_type} message sent to {receive_id}") + return True + except Exception as e: + logger.error(f"Error sending Feishu {msg_type} message: {e}") + return False + async def send(self, msg: OutboundMessage) -> None: - """Send a message through Feishu.""" + """Send a message through Feishu, including media (images/files) if present.""" if not self._client: logger.warning("Feishu client not initialized") return - + try: + import os + # Determine receive_id_type based on chat_id format # open_id starts with "ou_", chat_id starts with "oc_" if msg.chat_id.startswith("oc_"): receive_id_type = "chat_id" else: receive_id_type = "open_id" - - # Build card with markdown + table support - elements = self._build_card_elements(msg.content) - card = { - "config": {"wide_screen_mode": True}, - "elements": elements, - } - content = json.dumps(card, ensure_ascii=False) - - request = CreateMessageRequest.builder() \ - .receive_id_type(receive_id_type) \ - .request_body( - CreateMessageRequestBody.builder() - .receive_id(msg.chat_id) - .msg_type("interactive") - .content(content) - .build() - ).build() - - response = self._client.im.v1.message.create(request) - - if not response.success(): - logger.error( - f"Failed to send Feishu message: code={response.code}, " - f"msg={response.msg}, log_id={response.get_log_id()}" + + loop = asyncio.get_running_loop() + + # --- Send media attachments first --- + if msg.media: + for file_path in msg.media: + if not os.path.isfile(file_path): + logger.warning(f"Media file not found: {file_path}") + continue + + ext = os.path.splitext(file_path)[1].lower() + if ext in self._IMAGE_EXTS: + # Upload and send as image + image_key = await loop.run_in_executor(None, self._upload_image_sync, file_path) + if image_key: + content = json.dumps({"image_key": image_key}) + await loop.run_in_executor( + None, self._send_message_sync, + receive_id_type, msg.chat_id, "image", content, + ) + elif ext in self._AUDIO_EXTS: + # Upload and send as audio (voice message) + file_key = await loop.run_in_executor(None, self._upload_file_sync, file_path) + if file_key: + content = json.dumps({"file_key": file_key}) + await loop.run_in_executor( + None, self._send_message_sync, + receive_id_type, msg.chat_id, "audio", content, + ) + else: + # Upload and send as file + file_key = await loop.run_in_executor(None, self._upload_file_sync, file_path) + if file_key: + content = json.dumps({"file_key": file_key}) + await loop.run_in_executor( + None, self._send_message_sync, + receive_id_type, msg.chat_id, "file", content, + ) + + # --- Send text content (if any) --- + if msg.content and msg.content.strip(): + # Build card with markdown + table support + elements = self._build_card_elements(msg.content) + card = { + "config": {"wide_screen_mode": True}, + "elements": elements, + } + content = json.dumps(card, ensure_ascii=False) + await loop.run_in_executor( + None, self._send_message_sync, + receive_id_type, msg.chat_id, "interactive", content, ) - else: - logger.debug(f"Feishu message sent to {msg.chat_id}") - + except Exception as e: logger.error(f"Error sending Feishu message: {e}") From c86dbc9f45e4467d54ec0a56ce5597219071d8ee Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Thu, 19 Feb 2026 10:27:11 -0300 Subject: [PATCH 28/36] fix: wait for killed process after shell timeout to prevent fd leaks When a shell command times out, process.kill() is called but the process object was never awaited after that. This leaves subprocess pipes undrained and file descriptors open. If many commands time out, fd leaks accumulate. Add a bounded wait (5s) after kill to let the process fully terminate and release its resources. --- nanobot/agent/tools/shell.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index 18eff64..ceac6b7 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -81,6 +81,12 @@ class ExecTool(Tool): ) 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" output_parts = [] From d748e6eca33baf4b419c624b2a5e702b0d5b6b35 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 19 Feb 2026 17:28:13 +0000 Subject: [PATCH 29/36] fix: pin dependency version ranges --- pyproject.toml | 54 +++++++++++++++++++++++++------------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bbd6feb..64a884d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,37 +17,37 @@ classifiers = [ ] dependencies = [ - "typer>=0.9.0", - "litellm>=1.0.0", - "pydantic>=2.0.0", - "pydantic-settings>=2.0.0", - "websockets>=12.0", - "websocket-client>=1.6.0", - "httpx>=0.25.0", - "oauth-cli-kit>=0.1.1", - "loguru>=0.7.0", - "readability-lxml>=0.8.0", - "rich>=13.0.0", - "croniter>=2.0.0", - "dingtalk-stream>=0.4.0", - "python-telegram-bot[socks]>=21.0", - "lark-oapi>=1.0.0", - "socksio>=1.0.0", - "python-socketio>=5.11.0", - "msgpack>=1.0.8", - "slack-sdk>=3.26.0", - "slackify-markdown>=0.2.0", - "qq-botpy>=1.0.0", - "python-socks[asyncio]>=2.4.0", - "prompt-toolkit>=3.0.0", - "mcp>=1.0.0", - "json-repair>=0.30.0", + "typer>=0.20.0,<1.0.0", + "litellm>=1.81.5,<2.0.0", + "pydantic>=2.12.0,<3.0.0", + "pydantic-settings>=2.12.0,<3.0.0", + "websockets>=16.0,<17.0", + "websocket-client>=1.9.0,<2.0.0", + "httpx>=0.28.0,<1.0.0", + "oauth-cli-kit>=0.1.3,<1.0.0", + "loguru>=0.7.3,<1.0.0", + "readability-lxml>=0.8.4,<1.0.0", + "rich>=14.0.0,<15.0.0", + "croniter>=6.0.0,<7.0.0", + "dingtalk-stream>=0.24.0,<1.0.0", + "python-telegram-bot[socks]>=22.0,<23.0", + "lark-oapi>=1.5.0,<2.0.0", + "socksio>=1.0.0,<2.0.0", + "python-socketio>=5.16.0,<6.0.0", + "msgpack>=1.1.0,<2.0.0", + "slack-sdk>=3.39.0,<4.0.0", + "slackify-markdown>=0.2.0,<1.0.0", + "qq-botpy>=1.2.0,<2.0.0", + "python-socks[asyncio]>=2.8.0,<3.0.0", + "prompt-toolkit>=3.0.50,<4.0.0", + "mcp>=1.26.0,<2.0.0", + "json-repair>=0.57.0,<1.0.0", ] [project.optional-dependencies] dev = [ - "pytest>=7.0.0", - "pytest-asyncio>=0.21.0", + "pytest>=9.0.0,<10.0.0", + "pytest-asyncio>=1.3.0,<2.0.0", "ruff>=0.1.0", ] From 3890f1a7dd040050df64b11755d392c8b2689806 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 19 Feb 2026 17:33:08 +0000 Subject: [PATCH 30/36] refactor(feishu): clean up send() and remove dead code --- nanobot/channels/feishu.py | 85 +++++++++++--------------------------- 1 file changed, 24 insertions(+), 61 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 6f2881b..651d655 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -2,6 +2,7 @@ import asyncio import json +import os import re import threading from collections import OrderedDict @@ -267,7 +268,6 @@ class FeishuChannel(BaseChannel): before = protected[last_end:m.start()].strip() if before: elements.append({"tag": "markdown", "content": before}) - level = len(m.group(1)) text = m.group(2).strip() elements.append({ "tag": "div", @@ -288,13 +288,8 @@ class FeishuChannel(BaseChannel): return elements or [{"tag": "markdown", "content": content}] - # Image file extensions _IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"} - - # Audio file extensions (Feishu only supports opus for audio messages) _AUDIO_EXTS = {".opus"} - - # File type mapping for Feishu file upload API _FILE_TYPE_MAP = { ".opus": "opus", ".mp4": "mp4", ".pdf": "pdf", ".doc": "doc", ".docx": "doc", ".xls": "xls", ".xlsx": "xls", ".ppt": "ppt", ".pptx": "ppt", @@ -302,7 +297,6 @@ class FeishuChannel(BaseChannel): def _upload_image_sync(self, file_path: str) -> str | None: """Upload an image to Feishu and return the image_key.""" - import os try: with open(file_path, "rb") as f: request = CreateImageRequest.builder() \ @@ -326,7 +320,6 @@ class FeishuChannel(BaseChannel): def _upload_file_sync(self, file_path: str) -> str | None: """Upload a file to Feishu and return the file_key.""" - import os ext = os.path.splitext(file_path)[1].lower() file_type = self._FILE_TYPE_MAP.get(ext, "stream") file_name = os.path.basename(file_path) @@ -384,65 +377,35 @@ class FeishuChannel(BaseChannel): return try: - import os - - # Determine receive_id_type based on chat_id format - # open_id starts with "ou_", chat_id starts with "oc_" - if msg.chat_id.startswith("oc_"): - receive_id_type = "chat_id" - else: - receive_id_type = "open_id" - + receive_id_type = "chat_id" if msg.chat_id.startswith("oc_") else "open_id" loop = asyncio.get_running_loop() - # --- Send media attachments first --- - if msg.media: - for file_path in msg.media: - if not os.path.isfile(file_path): - logger.warning(f"Media file not found: {file_path}") - continue + for file_path in msg.media: + if not os.path.isfile(file_path): + logger.warning(f"Media file not found: {file_path}") + continue + ext = os.path.splitext(file_path)[1].lower() + if ext in self._IMAGE_EXTS: + key = await loop.run_in_executor(None, self._upload_image_sync, file_path) + if key: + await loop.run_in_executor( + None, self._send_message_sync, + receive_id_type, msg.chat_id, "image", json.dumps({"image_key": key}), + ) + else: + key = await loop.run_in_executor(None, self._upload_file_sync, file_path) + if key: + media_type = "audio" if ext in self._AUDIO_EXTS else "file" + await loop.run_in_executor( + None, self._send_message_sync, + receive_id_type, msg.chat_id, media_type, json.dumps({"file_key": key}), + ) - ext = os.path.splitext(file_path)[1].lower() - if ext in self._IMAGE_EXTS: - # Upload and send as image - image_key = await loop.run_in_executor(None, self._upload_image_sync, file_path) - if image_key: - content = json.dumps({"image_key": image_key}) - await loop.run_in_executor( - None, self._send_message_sync, - receive_id_type, msg.chat_id, "image", content, - ) - elif ext in self._AUDIO_EXTS: - # Upload and send as audio (voice message) - file_key = await loop.run_in_executor(None, self._upload_file_sync, file_path) - if file_key: - content = json.dumps({"file_key": file_key}) - await loop.run_in_executor( - None, self._send_message_sync, - receive_id_type, msg.chat_id, "audio", content, - ) - else: - # Upload and send as file - file_key = await loop.run_in_executor(None, self._upload_file_sync, file_path) - if file_key: - content = json.dumps({"file_key": file_key}) - await loop.run_in_executor( - None, self._send_message_sync, - receive_id_type, msg.chat_id, "file", content, - ) - - # --- Send text content (if any) --- if msg.content and msg.content.strip(): - # Build card with markdown + table support - elements = self._build_card_elements(msg.content) - card = { - "config": {"wide_screen_mode": True}, - "elements": elements, - } - content = json.dumps(card, ensure_ascii=False) + card = {"config": {"wide_screen_mode": True}, "elements": self._build_card_elements(msg.content)} await loop.run_in_executor( None, self._send_message_sync, - receive_id_type, msg.chat_id, "interactive", content, + receive_id_type, msg.chat_id, "interactive", json.dumps(card, ensure_ascii=False), ) except Exception as e: From b11f0ce6a9bc8159ed1a7a6937c7c93c80a54733 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 19 Feb 2026 17:39:44 +0000 Subject: [PATCH 31/36] fix: prefer explicit provider prefix over keyword match to fix Codex routing --- nanobot/config/schema.py | 21 ++++++--------------- nanobot/providers/registry.py | 16 +++++----------- 2 files changed, 11 insertions(+), 26 deletions(-) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 9558072..6a1257e 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -291,30 +291,21 @@ class Config(BaseSettings): model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else "" normalized_prefix = model_prefix.replace("-", "_") - def _matches_model_prefix(spec_name: str) -> bool: - if not model_prefix: - return False - return normalized_prefix == spec_name + def _kw_matches(kw: str) -> bool: + kw = kw.lower() + return kw in model_lower or kw.replace("-", "_") in model_normalized - def _keyword_matches(keyword: str) -> bool: - keyword_lower = keyword.lower() - return ( - keyword_lower in model_lower - or keyword_lower.replace("-", "_") in model_normalized - ) - - # Explicit provider prefix in model name wins over generic keyword matches. - # This prevents `github-copilot/...codex` from being treated as OpenAI Codex. + # Explicit provider prefix wins — prevents `github-copilot/...codex` matching openai_codex. for spec in PROVIDERS: p = getattr(self.providers, spec.name, None) - if p and _matches_model_prefix(spec.name): + if p and model_prefix and normalized_prefix == spec.name: if spec.is_oauth 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(_keyword_matches(kw) for kw in spec.keywords): + if p and any(_kw_matches(kw) for kw in spec.keywords): if spec.is_oauth or p.api_key: return p, spec.name diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index d986fe6..3071793 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -387,21 +387,15 @@ def find_by_model(model: str) -> ProviderSpec | None: model_normalized = model_lower.replace("-", "_") model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else "" normalized_prefix = model_prefix.replace("-", "_") + std_specs = [s for s in PROVIDERS if not s.is_gateway and not s.is_local] - # Prefer explicit provider prefix in model name. - for spec in PROVIDERS: - if spec.is_gateway or spec.is_local: - continue + # Prefer explicit provider prefix — prevents `github-copilot/...codex` matching openai_codex. + for spec in std_specs: if model_prefix and normalized_prefix == spec.name: return spec - for spec in PROVIDERS: - if spec.is_gateway or spec.is_local: - continue - if any( - kw in model_lower or kw.replace("-", "_") in model_normalized - for kw in spec.keywords - ): + for spec in std_specs: + if any(kw in model_lower or kw.replace("-", "_") in model_normalized for kw in spec.keywords): return spec return None From fbfb030a6eeabf6b3abf601f7cbe6514da876943 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 19 Feb 2026 17:48:09 +0000 Subject: [PATCH 32/36] chore: remove network-dependent test file for shell guard --- tests/test_exec_curl.py | 97 ----------------------------------------- 1 file changed, 97 deletions(-) delete mode 100644 tests/test_exec_curl.py diff --git a/tests/test_exec_curl.py b/tests/test_exec_curl.py deleted file mode 100644 index 2f53fcf..0000000 --- a/tests/test_exec_curl.py +++ /dev/null @@ -1,97 +0,0 @@ -r"""Tests for ExecTool safety guard — format pattern false positive. - -The old deny pattern `\b(format|mkfs|diskpart)\b` matched "format" inside -URLs (e.g. `curl https://wttr.in?format=3`) because `?` is a non-word -character, so `\b` fires between `?` and `f`. - -The fix splits the pattern: - - `(?:^|[;&|]\s*)format\b` — only matches `format` as a standalone command - - `\b(mkfs|diskpart)\b` — kept as-is (unique enough to not false-positive) -""" - -import re - -import pytest - -from nanobot.agent.tools.shell import ExecTool - - -# --- Guard regression: "format" in URLs must not be blocked --- - - -@pytest.mark.asyncio -async def test_curl_with_format_in_url_not_blocked(): - """curl with ?format= in URL should NOT be blocked by the guard.""" - tool = ExecTool(working_dir="/tmp") - result = await tool.execute( - command="curl -s 'https://wttr.in/Brooklyn?format=3'" - ) - assert "blocked by safety guard" not in result - - -@pytest.mark.asyncio -async def test_curl_with_format_in_post_body_not_blocked(): - """curl with 'format=json' in POST body should NOT be blocked.""" - tool = ExecTool(working_dir="/tmp") - result = await tool.execute( - command="curl -s -d 'format=json' https://httpbin.org/post" - ) - assert "blocked by safety guard" not in result - - -@pytest.mark.asyncio -async def test_curl_without_format_not_blocked(): - """Plain curl commands should pass the guard.""" - tool = ExecTool(working_dir="/tmp") - result = await tool.execute(command="curl -s https://httpbin.org/get") - assert "blocked by safety guard" not in result - - -# --- The guard still blocks actual format commands --- - - -@pytest.mark.asyncio -async def test_guard_blocks_standalone_format_command(): - """'format c:' as a standalone command must be blocked.""" - tool = ExecTool(working_dir="/tmp") - result = await tool.execute(command="format c:") - assert "blocked by safety guard" in result - - -@pytest.mark.asyncio -async def test_guard_blocks_format_after_semicolon(): - """'echo hi; format c:' must be blocked.""" - tool = ExecTool(working_dir="/tmp") - result = await tool.execute(command="echo hi; format c:") - assert "blocked by safety guard" in result - - -@pytest.mark.asyncio -async def test_guard_blocks_format_after_pipe(): - """'echo hi | format' must be blocked.""" - tool = ExecTool(working_dir="/tmp") - result = await tool.execute(command="echo hi | format") - assert "blocked by safety guard" in result - - -# --- Regex unit tests (no I/O) --- - - -def test_format_pattern_blocks_disk_commands(): - """The tightened pattern still catches actual format commands.""" - pattern = r"(?:^|[;&|]\s*)format\b" - - assert re.search(pattern, "format c:") - assert re.search(pattern, "echo hi; format c:") - assert re.search(pattern, "echo hi | format") - assert re.search(pattern, "cmd && format d:") - - -def test_format_pattern_allows_urls_and_flags(): - """The tightened pattern does NOT match format inside URLs or flags.""" - pattern = r"(?:^|[;&|]\s*)format\b" - - assert not re.search(pattern, "curl https://wttr.in?format=3") - assert not re.search(pattern, "echo --output-format=json") - assert not re.search(pattern, "curl -d 'format=json' https://api.example.com") - assert not re.search(pattern, "python -c 'print(\"{:format}\".format(1))'") From 53b83a38e2dc44b91e7890594abb1bd2220a6b03 Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Thu, 19 Feb 2026 17:19:36 -0300 Subject: [PATCH 33/36] fix: use loguru native formatting to prevent KeyError on messages containing curly braces Closes #857 --- nanobot/agent/loop.py | 12 ++++++------ nanobot/agent/subagent.py | 4 ++-- nanobot/agent/tools/mcp.py | 2 +- nanobot/bus/queue.py | 2 +- nanobot/channels/dingtalk.py | 18 +++++++++--------- nanobot/channels/discord.py | 10 +++++----- nanobot/channels/email.py | 4 ++-- nanobot/channels/feishu.py | 26 +++++++++++++------------- nanobot/channels/manager.py | 6 +++--- nanobot/channels/mochat.py | 24 ++++++++++++------------ nanobot/channels/qq.py | 6 +++--- nanobot/channels/slack.py | 8 ++++---- nanobot/channels/telegram.py | 16 ++++++++-------- nanobot/channels/whatsapp.py | 10 +++++----- nanobot/cron/service.py | 4 ++-- nanobot/heartbeat/service.py | 4 ++-- nanobot/providers/transcription.py | 2 +- nanobot/session/manager.py | 2 +- 18 files changed, 80 insertions(+), 80 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index e5a5183..cbab5aa 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -219,7 +219,7 @@ class AgentLoop: for tool_call in response.tool_calls: tools_used.append(tool_call.name) args_str = json.dumps(tool_call.arguments, ensure_ascii=False) - logger.info(f"Tool call: {tool_call.name}({args_str[:200]})") + logger.info("Tool call: {}({})", tool_call.name, args_str[:200]) result = await self.tools.execute(tool_call.name, tool_call.arguments) messages = self.context.add_tool_result( messages, tool_call.id, tool_call.name, result @@ -247,7 +247,7 @@ class AgentLoop: if response: await self.bus.publish_outbound(response) except Exception as e: - logger.error(f"Error processing message: {e}") + logger.error("Error processing message: {}", e) await self.bus.publish_outbound(OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, @@ -292,7 +292,7 @@ class AgentLoop: return await self._process_system_message(msg) preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content - logger.info(f"Processing message from {msg.channel}:{msg.sender_id}: {preview}") + logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview) key = session_key or msg.session_key session = self.sessions.get_or_create(key) @@ -344,7 +344,7 @@ class AgentLoop: final_content = "I've completed processing but have no response to give." preview = final_content[:120] + "..." if len(final_content) > 120 else final_content - logger.info(f"Response to {msg.channel}:{msg.sender_id}: {preview}") + logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview) session.add_message("user", msg.content) session.add_message("assistant", final_content, @@ -469,7 +469,7 @@ Respond with ONLY valid JSON, no markdown fences.""" text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip() result = json_repair.loads(text) if not isinstance(result, dict): - logger.warning(f"Memory consolidation: unexpected response type, skipping. Response: {text[:200]}") + logger.warning("Memory consolidation: unexpected response type, skipping. Response: {}", text[:200]) return if entry := result.get("history_entry"): @@ -484,7 +484,7 @@ Respond with ONLY valid JSON, no markdown fences.""" session.last_consolidated = len(session.messages) - keep_count logger.info(f"Memory consolidation done: {len(session.messages)} messages, last_consolidated={session.last_consolidated}") except Exception as e: - logger.error(f"Memory consolidation failed: {e}") + logger.error("Memory consolidation failed: {}", e) async def process_direct( self, diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 203836a..ae0e492 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -160,7 +160,7 @@ class SubagentManager: # Execute tools for tool_call in response.tool_calls: args_str = json.dumps(tool_call.arguments) - logger.debug(f"Subagent [{task_id}] executing: {tool_call.name} with arguments: {args_str}") + logger.debug("Subagent [{}] executing: {} with arguments: {}", task_id, tool_call.name, args_str) result = await tools.execute(tool_call.name, tool_call.arguments) messages.append({ "role": "tool", @@ -180,7 +180,7 @@ class SubagentManager: except Exception as e: error_msg = f"Error: {str(e)}" - logger.error(f"Subagent [{task_id}] failed: {e}") + logger.error("Subagent [{}] failed: {}", task_id, e) await self._announce_result(task_id, label, task, error_msg, origin, "error") async def _announce_result( diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index 1c8eac4..4e61923 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -77,4 +77,4 @@ async def connect_mcp_servers( logger.info(f"MCP server '{name}': connected, {len(tools.tools)} tools registered") except Exception as e: - logger.error(f"MCP server '{name}': failed to connect: {e}") + logger.error("MCP server '{}': failed to connect: {}", name, e) diff --git a/nanobot/bus/queue.py b/nanobot/bus/queue.py index 4123d06..554c0ec 100644 --- a/nanobot/bus/queue.py +++ b/nanobot/bus/queue.py @@ -62,7 +62,7 @@ class MessageBus: try: await callback(msg) except Exception as e: - logger.error(f"Error dispatching to {msg.channel}: {e}") + logger.error("Error dispatching to {}: {}", msg.channel, e) except asyncio.TimeoutError: continue diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py index 4a8cdd9..3ac233f 100644 --- a/nanobot/channels/dingtalk.py +++ b/nanobot/channels/dingtalk.py @@ -65,7 +65,7 @@ class NanobotDingTalkHandler(CallbackHandler): sender_id = chatbot_msg.sender_staff_id or chatbot_msg.sender_id sender_name = chatbot_msg.sender_nick or "Unknown" - logger.info(f"Received DingTalk message from {sender_name} ({sender_id}): {content}") + logger.info("Received DingTalk message from {} ({}): {}", sender_name, sender_id, content) # Forward to Nanobot via _on_message (non-blocking). # Store reference to prevent GC before task completes. @@ -78,7 +78,7 @@ class NanobotDingTalkHandler(CallbackHandler): return AckMessage.STATUS_OK, "OK" except Exception as e: - logger.error(f"Error processing DingTalk message: {e}") + logger.error("Error processing DingTalk message: {}", e) # Return OK to avoid retry loop from DingTalk server return AckMessage.STATUS_OK, "Error" @@ -142,13 +142,13 @@ class DingTalkChannel(BaseChannel): try: await self._client.start() except Exception as e: - logger.warning(f"DingTalk stream error: {e}") + logger.warning("DingTalk stream error: {}", e) if self._running: logger.info("Reconnecting DingTalk stream in 5 seconds...") await asyncio.sleep(5) except Exception as e: - logger.exception(f"Failed to start DingTalk channel: {e}") + logger.exception("Failed to start DingTalk channel: {}", e) async def stop(self) -> None: """Stop the DingTalk bot.""" @@ -186,7 +186,7 @@ class DingTalkChannel(BaseChannel): self._token_expiry = time.time() + int(res_data.get("expireIn", 7200)) - 60 return self._access_token except Exception as e: - logger.error(f"Failed to get DingTalk access token: {e}") + logger.error("Failed to get DingTalk access token: {}", e) return None async def send(self, msg: OutboundMessage) -> None: @@ -218,11 +218,11 @@ class DingTalkChannel(BaseChannel): try: resp = await self._http.post(url, json=data, headers=headers) if resp.status_code != 200: - logger.error(f"DingTalk send failed: {resp.text}") + logger.error("DingTalk send failed: {}", resp.text) else: logger.debug(f"DingTalk message sent to {msg.chat_id}") except Exception as e: - logger.error(f"Error sending DingTalk message: {e}") + logger.error("Error sending DingTalk message: {}", e) async def _on_message(self, content: str, sender_id: str, sender_name: str) -> None: """Handle incoming message (called by NanobotDingTalkHandler). @@ -231,7 +231,7 @@ class DingTalkChannel(BaseChannel): permission checks before publishing to the bus. """ try: - logger.info(f"DingTalk inbound: {content} from {sender_name}") + logger.info("DingTalk inbound: {} from {}", content, sender_name) await self._handle_message( sender_id=sender_id, chat_id=sender_id, # For private chat, chat_id == sender_id @@ -242,4 +242,4 @@ class DingTalkChannel(BaseChannel): }, ) except Exception as e: - logger.error(f"Error publishing DingTalk message: {e}") + logger.error("Error publishing DingTalk message: {}", e) diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index a76d6ac..ee54eed 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -51,7 +51,7 @@ class DiscordChannel(BaseChannel): except asyncio.CancelledError: break except Exception as e: - logger.warning(f"Discord gateway error: {e}") + logger.warning("Discord gateway error: {}", e) if self._running: logger.info("Reconnecting to Discord gateway in 5 seconds...") await asyncio.sleep(5) @@ -101,7 +101,7 @@ class DiscordChannel(BaseChannel): return except Exception as e: if attempt == 2: - logger.error(f"Error sending Discord message: {e}") + logger.error("Error sending Discord message: {}", e) else: await asyncio.sleep(1) finally: @@ -116,7 +116,7 @@ class DiscordChannel(BaseChannel): try: data = json.loads(raw) except json.JSONDecodeError: - logger.warning(f"Invalid JSON from Discord gateway: {raw[:100]}") + logger.warning("Invalid JSON from Discord gateway: {}", raw[:100]) continue op = data.get("op") @@ -175,7 +175,7 @@ class DiscordChannel(BaseChannel): try: await self._ws.send(json.dumps(payload)) except Exception as e: - logger.warning(f"Discord heartbeat failed: {e}") + logger.warning("Discord heartbeat failed: {}", e) break await asyncio.sleep(interval_s) @@ -219,7 +219,7 @@ class DiscordChannel(BaseChannel): media_paths.append(str(file_path)) content_parts.append(f"[attachment: {file_path}]") except Exception as e: - logger.warning(f"Failed to download Discord attachment: {e}") + logger.warning("Failed to download Discord attachment: {}", e) content_parts.append(f"[attachment: {filename} - download failed]") reply_to = (payload.get("referenced_message") or {}).get("id") diff --git a/nanobot/channels/email.py b/nanobot/channels/email.py index 0e47067..8a1ee79 100644 --- a/nanobot/channels/email.py +++ b/nanobot/channels/email.py @@ -94,7 +94,7 @@ class EmailChannel(BaseChannel): metadata=item.get("metadata", {}), ) except Exception as e: - logger.error(f"Email polling error: {e}") + logger.error("Email polling error: {}", e) await asyncio.sleep(poll_seconds) @@ -143,7 +143,7 @@ class EmailChannel(BaseChannel): try: await asyncio.to_thread(self._smtp_send, email_msg) except Exception as e: - logger.error(f"Error sending email to {to_addr}: {e}") + logger.error("Error sending email to {}: {}", to_addr, e) raise def _validate_config(self) -> bool: diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 651d655..6f62202 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -156,7 +156,7 @@ class FeishuChannel(BaseChannel): try: self._ws_client.start() except Exception as e: - logger.warning(f"Feishu WebSocket error: {e}") + logger.warning("Feishu WebSocket error: {}", e) if self._running: import time; time.sleep(5) @@ -177,7 +177,7 @@ class FeishuChannel(BaseChannel): try: self._ws_client.stop() except Exception as e: - logger.warning(f"Error stopping WebSocket client: {e}") + logger.warning("Error stopping WebSocket client: {}", e) logger.info("Feishu bot stopped") def _add_reaction_sync(self, message_id: str, emoji_type: str) -> None: @@ -194,11 +194,11 @@ class FeishuChannel(BaseChannel): response = self._client.im.v1.message_reaction.create(request) if not response.success(): - logger.warning(f"Failed to add reaction: code={response.code}, msg={response.msg}") + logger.warning("Failed to add reaction: code={}, msg={}", response.code, response.msg) else: logger.debug(f"Added {emoji_type} reaction to message {message_id}") except Exception as e: - logger.warning(f"Error adding reaction: {e}") + logger.warning("Error adding reaction: {}", e) async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> None: """ @@ -312,10 +312,10 @@ class FeishuChannel(BaseChannel): logger.debug(f"Uploaded image {os.path.basename(file_path)}: {image_key}") return image_key else: - logger.error(f"Failed to upload image: code={response.code}, msg={response.msg}") + logger.error("Failed to upload image: code={}, msg={}", response.code, response.msg) return None except Exception as e: - logger.error(f"Error uploading image {file_path}: {e}") + logger.error("Error uploading image {}: {}", file_path, e) return None def _upload_file_sync(self, file_path: str) -> str | None: @@ -339,10 +339,10 @@ class FeishuChannel(BaseChannel): logger.debug(f"Uploaded file {file_name}: {file_key}") return file_key else: - logger.error(f"Failed to upload file: code={response.code}, msg={response.msg}") + logger.error("Failed to upload file: code={}, msg={}", response.code, response.msg) return None except Exception as e: - logger.error(f"Error uploading file {file_path}: {e}") + logger.error("Error uploading file {}: {}", file_path, e) return None def _send_message_sync(self, receive_id_type: str, receive_id: str, msg_type: str, content: str) -> bool: @@ -360,14 +360,14 @@ class FeishuChannel(BaseChannel): response = self._client.im.v1.message.create(request) if not response.success(): logger.error( - f"Failed to send Feishu {msg_type} message: code={response.code}, " - f"msg={response.msg}, log_id={response.get_log_id()}" + "Failed to send Feishu {} message: code={}, msg={}, log_id={}", + msg_type, response.code, response.msg, response.get_log_id() ) return False logger.debug(f"Feishu {msg_type} message sent to {receive_id}") return True except Exception as e: - logger.error(f"Error sending Feishu {msg_type} message: {e}") + logger.error("Error sending Feishu {} message: {}", msg_type, e) return False async def send(self, msg: OutboundMessage) -> None: @@ -409,7 +409,7 @@ class FeishuChannel(BaseChannel): ) except Exception as e: - logger.error(f"Error sending Feishu message: {e}") + logger.error("Error sending Feishu message: {}", e) def _on_message_sync(self, data: "P2ImMessageReceiveV1") -> None: """ @@ -481,4 +481,4 @@ class FeishuChannel(BaseChannel): ) except Exception as e: - logger.error(f"Error processing Feishu message: {e}") + logger.error("Error processing Feishu message: {}", e) diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index e860d26..3e714c3 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -142,7 +142,7 @@ class ChannelManager: try: await channel.start() except Exception as e: - logger.error(f"Failed to start channel {name}: {e}") + logger.error("Failed to start channel {}: {}", name, e) async def start_all(self) -> None: """Start all channels and the outbound dispatcher.""" @@ -180,7 +180,7 @@ class ChannelManager: await channel.stop() logger.info(f"Stopped {name} channel") except Exception as e: - logger.error(f"Error stopping {name}: {e}") + logger.error("Error stopping {}: {}", name, e) async def _dispatch_outbound(self) -> None: """Dispatch outbound messages to the appropriate channel.""" @@ -198,7 +198,7 @@ class ChannelManager: try: await channel.send(msg) except Exception as e: - logger.error(f"Error sending to {msg.channel}: {e}") + logger.error("Error sending to {}: {}", msg.channel, e) else: logger.warning(f"Unknown channel: {msg.channel}") diff --git a/nanobot/channels/mochat.py b/nanobot/channels/mochat.py index 30c3dbf..e762dfd 100644 --- a/nanobot/channels/mochat.py +++ b/nanobot/channels/mochat.py @@ -322,7 +322,7 @@ class MochatChannel(BaseChannel): await self._api_send("/api/claw/sessions/send", "sessionId", target.id, content, msg.reply_to) except Exception as e: - logger.error(f"Failed to send Mochat message: {e}") + logger.error("Failed to send Mochat message: {}", e) # ---- config / init helpers --------------------------------------------- @@ -380,7 +380,7 @@ class MochatChannel(BaseChannel): @client.event async def connect_error(data: Any) -> None: - logger.error(f"Mochat websocket connect error: {data}") + logger.error("Mochat websocket connect error: {}", data) @client.on("claw.session.events") async def on_session_events(payload: dict[str, Any]) -> None: @@ -407,7 +407,7 @@ class MochatChannel(BaseChannel): ) return True except Exception as e: - logger.error(f"Failed to connect Mochat websocket: {e}") + logger.error("Failed to connect Mochat websocket: {}", e) try: await client.disconnect() except Exception: @@ -444,7 +444,7 @@ class MochatChannel(BaseChannel): "limit": self.config.watch_limit, }) if not ack.get("result"): - logger.error(f"Mochat subscribeSessions failed: {ack.get('message', 'unknown error')}") + logger.error("Mochat subscribeSessions failed: {}", ack.get('message', 'unknown error')) return False data = ack.get("data") @@ -466,7 +466,7 @@ class MochatChannel(BaseChannel): return True ack = await self._socket_call("com.claw.im.subscribePanels", {"panelIds": panel_ids}) if not ack.get("result"): - logger.error(f"Mochat subscribePanels failed: {ack.get('message', 'unknown error')}") + logger.error("Mochat subscribePanels failed: {}", ack.get('message', 'unknown error')) return False return True @@ -488,7 +488,7 @@ class MochatChannel(BaseChannel): try: await self._refresh_targets(subscribe_new=self._ws_ready) except Exception as e: - logger.warning(f"Mochat refresh failed: {e}") + logger.warning("Mochat refresh failed: {}", e) if self._fallback_mode: await self._ensure_fallback_workers() @@ -502,7 +502,7 @@ class MochatChannel(BaseChannel): try: response = await self._post_json("/api/claw/sessions/list", {}) except Exception as e: - logger.warning(f"Mochat listSessions failed: {e}") + logger.warning("Mochat listSessions failed: {}", e) return sessions = response.get("sessions") @@ -536,7 +536,7 @@ class MochatChannel(BaseChannel): try: response = await self._post_json("/api/claw/groups/get", {}) except Exception as e: - logger.warning(f"Mochat getWorkspaceGroup failed: {e}") + logger.warning("Mochat getWorkspaceGroup failed: {}", e) return raw_panels = response.get("panels") @@ -598,7 +598,7 @@ class MochatChannel(BaseChannel): except asyncio.CancelledError: break except Exception as e: - logger.warning(f"Mochat watch fallback error ({session_id}): {e}") + logger.warning("Mochat watch fallback error ({}): {}", session_id, e) await asyncio.sleep(max(0.1, self.config.retry_delay_ms / 1000.0)) async def _panel_poll_worker(self, panel_id: str) -> None: @@ -625,7 +625,7 @@ class MochatChannel(BaseChannel): except asyncio.CancelledError: break except Exception as e: - logger.warning(f"Mochat panel polling error ({panel_id}): {e}") + logger.warning("Mochat panel polling error ({}): {}", panel_id, e) await asyncio.sleep(sleep_s) # ---- inbound event processing ------------------------------------------ @@ -836,7 +836,7 @@ class MochatChannel(BaseChannel): try: data = json.loads(self._cursor_path.read_text("utf-8")) except Exception as e: - logger.warning(f"Failed to read Mochat cursor file: {e}") + logger.warning("Failed to read Mochat cursor file: {}", e) return cursors = data.get("cursors") if isinstance(data, dict) else None if isinstance(cursors, dict): @@ -852,7 +852,7 @@ class MochatChannel(BaseChannel): "cursors": self._session_cursor, }, ensure_ascii=False, indent=2) + "\n", "utf-8") except Exception as e: - logger.warning(f"Failed to save Mochat cursor file: {e}") + logger.warning("Failed to save Mochat cursor file: {}", e) # ---- HTTP helpers ------------------------------------------------------ diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 0e8fe66..1d00bc7 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -80,7 +80,7 @@ class QQChannel(BaseChannel): try: await self._client.start(appid=self.config.app_id, secret=self.config.secret) except Exception as e: - logger.warning(f"QQ bot error: {e}") + logger.warning("QQ bot error: {}", e) if self._running: logger.info("Reconnecting QQ bot in 5 seconds...") await asyncio.sleep(5) @@ -108,7 +108,7 @@ class QQChannel(BaseChannel): content=msg.content, ) except Exception as e: - logger.error(f"Error sending QQ message: {e}") + logger.error("Error sending QQ message: {}", e) async def _on_message(self, data: "C2CMessage") -> None: """Handle incoming message from QQ.""" @@ -131,4 +131,4 @@ class QQChannel(BaseChannel): metadata={"message_id": data.id}, ) except Exception as e: - logger.error(f"Error handling QQ message: {e}") + logger.error("Error handling QQ message: {}", e) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index dca5055..7dd2971 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -55,7 +55,7 @@ class SlackChannel(BaseChannel): self._bot_user_id = auth.get("user_id") logger.info(f"Slack bot connected as {self._bot_user_id}") except Exception as e: - logger.warning(f"Slack auth_test failed: {e}") + logger.warning("Slack auth_test failed: {}", e) logger.info("Starting Slack Socket Mode client...") await self._socket_client.connect() @@ -70,7 +70,7 @@ class SlackChannel(BaseChannel): try: await self._socket_client.close() except Exception as e: - logger.warning(f"Slack socket close failed: {e}") + logger.warning("Slack socket close failed: {}", e) self._socket_client = None async def send(self, msg: OutboundMessage) -> None: @@ -90,7 +90,7 @@ class SlackChannel(BaseChannel): thread_ts=thread_ts if use_thread else None, ) except Exception as e: - logger.error(f"Error sending Slack message: {e}") + logger.error("Error sending Slack message: {}", e) async def _on_socket_request( self, @@ -164,7 +164,7 @@ class SlackChannel(BaseChannel): timestamp=event.get("ts"), ) except Exception as e: - logger.debug(f"Slack reactions_add failed: {e}") + logger.debug("Slack reactions_add failed: {}", e) await self._handle_message( sender_id=sender_id, diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 39924b3..42db489 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -171,7 +171,7 @@ class TelegramChannel(BaseChannel): await self._app.bot.set_my_commands(self.BOT_COMMANDS) logger.debug("Telegram bot commands registered") except Exception as e: - logger.warning(f"Failed to register bot commands: {e}") + logger.warning("Failed to register bot commands: {}", e) # Start polling (this runs until stopped) await self._app.updater.start_polling( @@ -238,7 +238,7 @@ class TelegramChannel(BaseChannel): await sender(chat_id=chat_id, **{param: f}) except Exception as e: filename = media_path.rsplit("/", 1)[-1] - logger.error(f"Failed to send media {media_path}: {e}") + logger.error("Failed to send media {}: {}", media_path, e) await self._app.bot.send_message(chat_id=chat_id, text=f"[Failed to send: {filename}]") # Send text content @@ -248,11 +248,11 @@ class TelegramChannel(BaseChannel): html = _markdown_to_telegram_html(chunk) await self._app.bot.send_message(chat_id=chat_id, text=html, parse_mode="HTML") except Exception as e: - logger.warning(f"HTML parse failed, falling back to plain text: {e}") + logger.warning("HTML parse failed, falling back to plain text: {}", e) try: await self._app.bot.send_message(chat_id=chat_id, text=chunk) except Exception as e2: - logger.error(f"Error sending Telegram message: {e2}") + logger.error("Error sending Telegram message: {}", e2) async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle /start command.""" @@ -353,12 +353,12 @@ class TelegramChannel(BaseChannel): logger.debug(f"Downloaded {media_type} to {file_path}") except Exception as e: - logger.error(f"Failed to download media: {e}") + logger.error("Failed to download media: {}", e) content_parts.append(f"[{media_type}: download failed]") content = "\n".join(content_parts) if content_parts else "[empty message]" - logger.debug(f"Telegram message from {sender_id}: {content[:50]}...") + logger.debug("Telegram message from {}: {}...", sender_id, content[:50]) str_chat_id = str(chat_id) @@ -401,11 +401,11 @@ class TelegramChannel(BaseChannel): except asyncio.CancelledError: pass except Exception as e: - logger.debug(f"Typing indicator stopped for {chat_id}: {e}") + logger.debug("Typing indicator stopped for {}: {}", chat_id, e) async def _on_error(self, update: object, context: ContextTypes.DEFAULT_TYPE) -> None: """Log polling / handler errors instead of silently swallowing them.""" - logger.error(f"Telegram error: {context.error}") + logger.error("Telegram error: {}", context.error) def _get_extension(self, media_type: str, mime_type: str | None) -> str: """Get file extension based on media type.""" diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index 0cf2dd7..4d12360 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -53,14 +53,14 @@ class WhatsAppChannel(BaseChannel): try: await self._handle_bridge_message(message) except Exception as e: - logger.error(f"Error handling bridge message: {e}") + logger.error("Error handling bridge message: {}", e) except asyncio.CancelledError: break except Exception as e: self._connected = False self._ws = None - logger.warning(f"WhatsApp bridge connection error: {e}") + logger.warning("WhatsApp bridge connection error: {}", e) if self._running: logger.info("Reconnecting in 5 seconds...") @@ -89,14 +89,14 @@ class WhatsAppChannel(BaseChannel): } await self._ws.send(json.dumps(payload)) except Exception as e: - logger.error(f"Error sending WhatsApp message: {e}") + logger.error("Error sending WhatsApp message: {}", e) async def _handle_bridge_message(self, raw: str) -> None: """Handle a message from the bridge.""" try: data = json.loads(raw) except json.JSONDecodeError: - logger.warning(f"Invalid JSON from bridge: {raw[:100]}") + logger.warning("Invalid JSON from bridge: {}", raw[:100]) return msg_type = data.get("type") @@ -145,4 +145,4 @@ class WhatsAppChannel(BaseChannel): logger.info("Scan QR code in the bridge terminal to connect WhatsApp") elif msg_type == "error": - logger.error(f"WhatsApp bridge error: {data.get('error')}") + logger.error("WhatsApp bridge error: {}", data.get('error')) diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index 14666e8..d2b9ef7 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -99,7 +99,7 @@ class CronService: )) self._store = CronStore(jobs=jobs) except Exception as e: - logger.warning(f"Failed to load cron store: {e}") + logger.warning("Failed to load cron store: {}", e) self._store = CronStore() else: self._store = CronStore() @@ -236,7 +236,7 @@ class CronService: except Exception as e: job.state.last_status = "error" job.state.last_error = str(e) - logger.error(f"Cron: job '{job.name}' failed: {e}") + logger.error("Cron: job '{}' failed: {}", job.name, e) job.state.last_run_at_ms = start_ms job.updated_at_ms = _now_ms() diff --git a/nanobot/heartbeat/service.py b/nanobot/heartbeat/service.py index 221ed27..8bdc78f 100644 --- a/nanobot/heartbeat/service.py +++ b/nanobot/heartbeat/service.py @@ -97,7 +97,7 @@ class HeartbeatService: except asyncio.CancelledError: break except Exception as e: - logger.error(f"Heartbeat error: {e}") + logger.error("Heartbeat error: {}", e) async def _tick(self) -> None: """Execute a single heartbeat tick.""" @@ -121,7 +121,7 @@ class HeartbeatService: logger.info(f"Heartbeat: completed task") except Exception as e: - logger.error(f"Heartbeat execution failed: {e}") + logger.error("Heartbeat execution failed: {}", e) async def trigger_now(self) -> str | None: """Manually trigger a heartbeat.""" diff --git a/nanobot/providers/transcription.py b/nanobot/providers/transcription.py index 8ce909b..eb5969d 100644 --- a/nanobot/providers/transcription.py +++ b/nanobot/providers/transcription.py @@ -61,5 +61,5 @@ class GroqTranscriptionProvider: return data.get("text", "") except Exception as e: - logger.error(f"Groq transcription error: {e}") + logger.error("Groq transcription error: {}", e) return "" diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 752fce4..44dcecb 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -144,7 +144,7 @@ class SessionManager: last_consolidated=last_consolidated ) except Exception as e: - logger.warning(f"Failed to load session {key}: {e}") + logger.warning("Failed to load session {}: {}", key, e) return None def save(self, session: Session) -> None: From 0001f286b578b6ecf9634b47c6605978038e5a61 Mon Sep 17 00:00:00 2001 From: AlexanderMerkel Date: Thu, 19 Feb 2026 19:00:25 -0700 Subject: [PATCH 34/36] fix: remove dead pub/sub code from MessageBus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `subscribe_outbound()`, `dispatch_outbound()`, and `stop()` have zero callers — `ChannelManager._dispatch_outbound()` handles all outbound routing via `consume_outbound()` directly. Remove the dead methods and their unused imports (`Callable`, `Awaitable`, `logger`). Co-Authored-By: Claude Opus 4.6 --- nanobot/bus/queue.py | 53 +++++++------------------------------------- 1 file changed, 8 insertions(+), 45 deletions(-) diff --git a/nanobot/bus/queue.py b/nanobot/bus/queue.py index 4123d06..7c0616f 100644 --- a/nanobot/bus/queue.py +++ b/nanobot/bus/queue.py @@ -1,9 +1,6 @@ """Async message queue for decoupled channel-agent communication.""" import asyncio -from typing import Callable, Awaitable - -from loguru import logger from nanobot.bus.events import InboundMessage, OutboundMessage @@ -11,70 +8,36 @@ from nanobot.bus.events import InboundMessage, OutboundMessage class MessageBus: """ Async message bus that decouples chat channels from the agent core. - + Channels push messages to the inbound queue, and the agent processes them and pushes responses to the outbound queue. """ - + def __init__(self): self.inbound: asyncio.Queue[InboundMessage] = asyncio.Queue() self.outbound: asyncio.Queue[OutboundMessage] = asyncio.Queue() - self._outbound_subscribers: dict[str, list[Callable[[OutboundMessage], Awaitable[None]]]] = {} - self._running = False - + async def publish_inbound(self, msg: InboundMessage) -> None: """Publish a message from a channel to the agent.""" await self.inbound.put(msg) - + async def consume_inbound(self) -> InboundMessage: """Consume the next inbound message (blocks until available).""" return await self.inbound.get() - + async def publish_outbound(self, msg: OutboundMessage) -> None: """Publish a response from the agent to channels.""" await self.outbound.put(msg) - + async def consume_outbound(self) -> OutboundMessage: """Consume the next outbound message (blocks until available).""" return await self.outbound.get() - - def subscribe_outbound( - self, - channel: str, - callback: Callable[[OutboundMessage], Awaitable[None]] - ) -> None: - """Subscribe to outbound messages for a specific channel.""" - if channel not in self._outbound_subscribers: - self._outbound_subscribers[channel] = [] - self._outbound_subscribers[channel].append(callback) - - async def dispatch_outbound(self) -> None: - """ - Dispatch outbound messages to subscribed channels. - Run this as a background task. - """ - self._running = True - while self._running: - try: - msg = await asyncio.wait_for(self.outbound.get(), timeout=1.0) - subscribers = self._outbound_subscribers.get(msg.channel, []) - for callback in subscribers: - try: - await callback(msg) - except Exception as e: - logger.error(f"Error dispatching to {msg.channel}: {e}") - except asyncio.TimeoutError: - continue - - def stop(self) -> None: - """Stop the dispatcher loop.""" - self._running = False - + @property def inbound_size(self) -> int: """Number of pending inbound messages.""" return self.inbound.qsize() - + @property def outbound_size(self) -> int: """Number of pending outbound messages.""" From 37252a4226214367abea1cef0fae4591b2dc00c4 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 20 Feb 2026 07:55:34 +0000 Subject: [PATCH 35/36] fix: complete loguru native formatting migration across all files --- nanobot/agent/loop.py | 12 ++++++------ nanobot/agent/subagent.py | 8 ++++---- nanobot/agent/tools/mcp.py | 6 +++--- nanobot/channels/dingtalk.py | 2 +- nanobot/channels/discord.py | 2 +- nanobot/channels/email.py | 2 +- nanobot/channels/feishu.py | 10 +++++----- nanobot/channels/manager.py | 24 ++++++++++++------------ nanobot/channels/qq.py | 2 +- nanobot/channels/slack.py | 4 ++-- nanobot/channels/telegram.py | 8 ++++---- nanobot/channels/whatsapp.py | 8 ++++---- nanobot/cron/service.py | 10 +++++----- nanobot/heartbeat/service.py | 4 ++-- nanobot/providers/transcription.py | 2 +- nanobot/session/manager.py | 2 +- 16 files changed, 53 insertions(+), 53 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index cbab5aa..1620cb0 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -365,7 +365,7 @@ class AgentLoop: The chat_id field contains "original_channel:original_chat_id" to route the response back to the correct destination. """ - logger.info(f"Processing system message from {msg.sender_id}") + logger.info("Processing system message from {}", msg.sender_id) # Parse origin from chat_id (format: "channel:chat_id") if ":" in msg.chat_id: @@ -413,22 +413,22 @@ class AgentLoop: if archive_all: old_messages = session.messages keep_count = 0 - logger.info(f"Memory consolidation (archive_all): {len(session.messages)} total messages archived") + logger.info("Memory consolidation (archive_all): {} total messages archived", len(session.messages)) else: keep_count = self.memory_window // 2 if len(session.messages) <= keep_count: - logger.debug(f"Session {session.key}: No consolidation needed (messages={len(session.messages)}, keep={keep_count})") + logger.debug("Session {}: No consolidation needed (messages={}, keep={})", session.key, len(session.messages), keep_count) return messages_to_process = len(session.messages) - session.last_consolidated if messages_to_process <= 0: - logger.debug(f"Session {session.key}: No new messages to consolidate (last_consolidated={session.last_consolidated}, total={len(session.messages)})") + logger.debug("Session {}: No new messages to consolidate (last_consolidated={}, total={})", session.key, session.last_consolidated, len(session.messages)) return old_messages = session.messages[session.last_consolidated:-keep_count] if not old_messages: return - logger.info(f"Memory consolidation started: {len(session.messages)} total, {len(old_messages)} new to consolidate, {keep_count} keep") + logger.info("Memory consolidation started: {} total, {} new to consolidate, {} keep", len(session.messages), len(old_messages), keep_count) lines = [] for m in old_messages: @@ -482,7 +482,7 @@ Respond with ONLY valid JSON, no markdown fences.""" session.last_consolidated = 0 else: session.last_consolidated = len(session.messages) - keep_count - logger.info(f"Memory consolidation done: {len(session.messages)} messages, last_consolidated={session.last_consolidated}") + logger.info("Memory consolidation done: {} messages, last_consolidated={}", len(session.messages), session.last_consolidated) except Exception as e: logger.error("Memory consolidation failed: {}", e) diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index ae0e492..7d48cc4 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -86,7 +86,7 @@ class SubagentManager: # Cleanup when done bg_task.add_done_callback(lambda _: self._running_tasks.pop(task_id, None)) - logger.info(f"Spawned subagent [{task_id}]: {display_label}") + logger.info("Spawned subagent [{}]: {}", task_id, display_label) return f"Subagent [{display_label}] started (id: {task_id}). I'll notify you when it completes." async def _run_subagent( @@ -97,7 +97,7 @@ class SubagentManager: origin: dict[str, str], ) -> None: """Execute the subagent task and announce the result.""" - logger.info(f"Subagent [{task_id}] starting task: {label}") + logger.info("Subagent [{}] starting task: {}", task_id, label) try: # Build subagent tools (no message tool, no spawn tool) @@ -175,7 +175,7 @@ class SubagentManager: if final_result is None: final_result = "Task completed but no final response was generated." - logger.info(f"Subagent [{task_id}] completed successfully") + logger.info("Subagent [{}] completed successfully", task_id) await self._announce_result(task_id, label, task, final_result, origin, "ok") except Exception as e: @@ -213,7 +213,7 @@ Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not men ) await self.bus.publish_inbound(msg) - logger.debug(f"Subagent [{task_id}] announced result to {origin['channel']}:{origin['chat_id']}") + logger.debug("Subagent [{}] announced result to {}:{}", task_id, origin['channel'], origin['chat_id']) def _build_subagent_prompt(self, task: str) -> str: """Build a focused system prompt for the subagent.""" diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index 4e61923..7d9033d 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -63,7 +63,7 @@ async def connect_mcp_servers( streamable_http_client(cfg.url) ) else: - logger.warning(f"MCP server '{name}': no command or url configured, skipping") + logger.warning("MCP server '{}': no command or url configured, skipping", name) continue session = await stack.enter_async_context(ClientSession(read, write)) @@ -73,8 +73,8 @@ async def connect_mcp_servers( for tool_def in tools.tools: wrapper = MCPToolWrapper(session, name, tool_def) registry.register(wrapper) - logger.debug(f"MCP: registered tool '{wrapper.name}' from server '{name}'") + logger.debug("MCP: registered tool '{}' from server '{}'", wrapper.name, name) - logger.info(f"MCP server '{name}': connected, {len(tools.tools)} tools registered") + logger.info("MCP server '{}': connected, {} tools registered", name, len(tools.tools)) except Exception as e: logger.error("MCP server '{}': failed to connect: {}", name, e) diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py index 3ac233f..f6dca30 100644 --- a/nanobot/channels/dingtalk.py +++ b/nanobot/channels/dingtalk.py @@ -220,7 +220,7 @@ class DingTalkChannel(BaseChannel): if resp.status_code != 200: logger.error("DingTalk send failed: {}", resp.text) else: - logger.debug(f"DingTalk message sent to {msg.chat_id}") + logger.debug("DingTalk message sent to {}", msg.chat_id) except Exception as e: logger.error("Error sending DingTalk message: {}", e) diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index ee54eed..8baecbf 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -94,7 +94,7 @@ class DiscordChannel(BaseChannel): if response.status_code == 429: data = response.json() retry_after = float(data.get("retry_after", 1.0)) - logger.warning(f"Discord rate limited, retrying in {retry_after}s") + logger.warning("Discord rate limited, retrying in {}s", retry_after) await asyncio.sleep(retry_after) continue response.raise_for_status() diff --git a/nanobot/channels/email.py b/nanobot/channels/email.py index 8a1ee79..1b6f46b 100644 --- a/nanobot/channels/email.py +++ b/nanobot/channels/email.py @@ -162,7 +162,7 @@ class EmailChannel(BaseChannel): missing.append("smtp_password") if missing: - logger.error(f"Email channel not configured, missing: {', '.join(missing)}") + logger.error("Email channel not configured, missing: {}", ', '.join(missing)) return False return True diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 6f62202..c17bf1a 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -196,7 +196,7 @@ class FeishuChannel(BaseChannel): if not response.success(): logger.warning("Failed to add reaction: code={}, msg={}", response.code, response.msg) else: - logger.debug(f"Added {emoji_type} reaction to message {message_id}") + logger.debug("Added {} reaction to message {}", emoji_type, message_id) except Exception as e: logger.warning("Error adding reaction: {}", e) @@ -309,7 +309,7 @@ class FeishuChannel(BaseChannel): response = self._client.im.v1.image.create(request) if response.success(): image_key = response.data.image_key - logger.debug(f"Uploaded image {os.path.basename(file_path)}: {image_key}") + logger.debug("Uploaded image {}: {}", os.path.basename(file_path), image_key) return image_key else: logger.error("Failed to upload image: code={}, msg={}", response.code, response.msg) @@ -336,7 +336,7 @@ class FeishuChannel(BaseChannel): response = self._client.im.v1.file.create(request) if response.success(): file_key = response.data.file_key - logger.debug(f"Uploaded file {file_name}: {file_key}") + logger.debug("Uploaded file {}: {}", file_name, file_key) return file_key else: logger.error("Failed to upload file: code={}, msg={}", response.code, response.msg) @@ -364,7 +364,7 @@ class FeishuChannel(BaseChannel): msg_type, response.code, response.msg, response.get_log_id() ) return False - logger.debug(f"Feishu {msg_type} message sent to {receive_id}") + logger.debug("Feishu {} message sent to {}", msg_type, receive_id) return True except Exception as e: logger.error("Error sending Feishu {} message: {}", msg_type, e) @@ -382,7 +382,7 @@ class FeishuChannel(BaseChannel): for file_path in msg.media: if not os.path.isfile(file_path): - logger.warning(f"Media file not found: {file_path}") + logger.warning("Media file not found: {}", file_path) continue ext = os.path.splitext(file_path)[1].lower() if ext in self._IMAGE_EXTS: diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 3e714c3..6fbab04 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -45,7 +45,7 @@ class ChannelManager: ) logger.info("Telegram channel enabled") except ImportError as e: - logger.warning(f"Telegram channel not available: {e}") + logger.warning("Telegram channel not available: {}", e) # WhatsApp channel if self.config.channels.whatsapp.enabled: @@ -56,7 +56,7 @@ class ChannelManager: ) logger.info("WhatsApp channel enabled") except ImportError as e: - logger.warning(f"WhatsApp channel not available: {e}") + logger.warning("WhatsApp channel not available: {}", e) # Discord channel if self.config.channels.discord.enabled: @@ -67,7 +67,7 @@ class ChannelManager: ) logger.info("Discord channel enabled") except ImportError as e: - logger.warning(f"Discord channel not available: {e}") + logger.warning("Discord channel not available: {}", e) # Feishu channel if self.config.channels.feishu.enabled: @@ -78,7 +78,7 @@ class ChannelManager: ) logger.info("Feishu channel enabled") except ImportError as e: - logger.warning(f"Feishu channel not available: {e}") + logger.warning("Feishu channel not available: {}", e) # Mochat channel if self.config.channels.mochat.enabled: @@ -90,7 +90,7 @@ class ChannelManager: ) logger.info("Mochat channel enabled") except ImportError as e: - logger.warning(f"Mochat channel not available: {e}") + logger.warning("Mochat channel not available: {}", e) # DingTalk channel if self.config.channels.dingtalk.enabled: @@ -101,7 +101,7 @@ class ChannelManager: ) logger.info("DingTalk channel enabled") except ImportError as e: - logger.warning(f"DingTalk channel not available: {e}") + logger.warning("DingTalk channel not available: {}", e) # Email channel if self.config.channels.email.enabled: @@ -112,7 +112,7 @@ class ChannelManager: ) logger.info("Email channel enabled") except ImportError as e: - logger.warning(f"Email channel not available: {e}") + logger.warning("Email channel not available: {}", e) # Slack channel if self.config.channels.slack.enabled: @@ -123,7 +123,7 @@ class ChannelManager: ) logger.info("Slack channel enabled") except ImportError as e: - logger.warning(f"Slack channel not available: {e}") + logger.warning("Slack channel not available: {}", e) # QQ channel if self.config.channels.qq.enabled: @@ -135,7 +135,7 @@ class ChannelManager: ) logger.info("QQ channel enabled") except ImportError as e: - logger.warning(f"QQ channel not available: {e}") + logger.warning("QQ channel not available: {}", e) async def _start_channel(self, name: str, channel: BaseChannel) -> None: """Start a channel and log any exceptions.""" @@ -156,7 +156,7 @@ class ChannelManager: # Start channels tasks = [] for name, channel in self.channels.items(): - logger.info(f"Starting {name} channel...") + logger.info("Starting {} channel...", name) tasks.append(asyncio.create_task(self._start_channel(name, channel))) # Wait for all to complete (they should run forever) @@ -178,7 +178,7 @@ class ChannelManager: for name, channel in self.channels.items(): try: await channel.stop() - logger.info(f"Stopped {name} channel") + logger.info("Stopped {} channel", name) except Exception as e: logger.error("Error stopping {}: {}", name, e) @@ -200,7 +200,7 @@ class ChannelManager: except Exception as e: logger.error("Error sending to {}: {}", msg.channel, e) else: - logger.warning(f"Unknown channel: {msg.channel}") + logger.warning("Unknown channel: {}", msg.channel) except asyncio.TimeoutError: continue diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 1d00bc7..16cbfb8 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -34,7 +34,7 @@ def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]": super().__init__(intents=intents) async def on_ready(self): - logger.info(f"QQ bot ready: {self.robot.name}") + logger.info("QQ bot ready: {}", self.robot.name) async def on_c2c_message_create(self, message: "C2CMessage"): await channel._on_message(message) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index 7dd2971..79cbe76 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -36,7 +36,7 @@ class SlackChannel(BaseChannel): logger.error("Slack bot/app token not configured") return if self.config.mode != "socket": - logger.error(f"Unsupported Slack mode: {self.config.mode}") + logger.error("Unsupported Slack mode: {}", self.config.mode) return self._running = True @@ -53,7 +53,7 @@ class SlackChannel(BaseChannel): try: auth = await self._web_client.auth_test() self._bot_user_id = auth.get("user_id") - logger.info(f"Slack bot connected as {self._bot_user_id}") + logger.info("Slack bot connected as {}", self._bot_user_id) except Exception as e: logger.warning("Slack auth_test failed: {}", e) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 42db489..fa36c98 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -165,7 +165,7 @@ class TelegramChannel(BaseChannel): # Get bot info and register command menu bot_info = await self._app.bot.get_me() - logger.info(f"Telegram bot @{bot_info.username} connected") + logger.info("Telegram bot @{} connected", bot_info.username) try: await self._app.bot.set_my_commands(self.BOT_COMMANDS) @@ -221,7 +221,7 @@ class TelegramChannel(BaseChannel): try: chat_id = int(msg.chat_id) except ValueError: - logger.error(f"Invalid chat_id: {msg.chat_id}") + logger.error("Invalid chat_id: {}", msg.chat_id) return # Send media files @@ -344,14 +344,14 @@ class TelegramChannel(BaseChannel): transcriber = GroqTranscriptionProvider(api_key=self.groq_api_key) transcription = await transcriber.transcribe(file_path) if transcription: - logger.info(f"Transcribed {media_type}: {transcription[:50]}...") + 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(f"Downloaded {media_type} to {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]") diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index 4d12360..f3e14d9 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -34,7 +34,7 @@ class WhatsAppChannel(BaseChannel): bridge_url = self.config.bridge_url - logger.info(f"Connecting to WhatsApp bridge at {bridge_url}...") + logger.info("Connecting to WhatsApp bridge at {}...", bridge_url) self._running = True @@ -112,11 +112,11 @@ class WhatsAppChannel(BaseChannel): # Extract just the phone number or lid as chat_id user_id = pn if pn else sender sender_id = user_id.split("@")[0] if "@" in user_id else user_id - logger.info(f"Sender {sender}") + logger.info("Sender {}", sender) # Handle voice transcription if it's a voice message if content == "[Voice Message]": - logger.info(f"Voice message received from {sender_id}, but direct download from bridge is not yet supported.") + logger.info("Voice message received from {}, but direct download from bridge is not yet supported.", sender_id) content = "[Voice Message: Transcription not available for WhatsApp yet]" await self._handle_message( @@ -133,7 +133,7 @@ class WhatsAppChannel(BaseChannel): elif msg_type == "status": # Connection status update status = data.get("status") - logger.info(f"WhatsApp status: {status}") + logger.info("WhatsApp status: {}", status) if status == "connected": self._connected = True diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index d2b9ef7..4c14ef7 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -157,7 +157,7 @@ class CronService: self._recompute_next_runs() self._save_store() self._arm_timer() - logger.info(f"Cron service started with {len(self._store.jobs if self._store else [])} jobs") + logger.info("Cron service started with {} jobs", len(self._store.jobs if self._store else [])) def stop(self) -> None: """Stop the cron service.""" @@ -222,7 +222,7 @@ class CronService: async def _execute_job(self, job: CronJob) -> None: """Execute a single job.""" start_ms = _now_ms() - logger.info(f"Cron: executing job '{job.name}' ({job.id})") + logger.info("Cron: executing job '{}' ({})", job.name, job.id) try: response = None @@ -231,7 +231,7 @@ class CronService: job.state.last_status = "ok" job.state.last_error = None - logger.info(f"Cron: job '{job.name}' completed") + logger.info("Cron: job '{}' completed", job.name) except Exception as e: job.state.last_status = "error" @@ -296,7 +296,7 @@ class CronService: self._save_store() self._arm_timer() - logger.info(f"Cron: added job '{name}' ({job.id})") + logger.info("Cron: added job '{}' ({})", name, job.id) return job def remove_job(self, job_id: str) -> bool: @@ -309,7 +309,7 @@ class CronService: if removed: self._save_store() self._arm_timer() - logger.info(f"Cron: removed job {job_id}") + logger.info("Cron: removed job {}", job_id) return removed diff --git a/nanobot/heartbeat/service.py b/nanobot/heartbeat/service.py index 8bdc78f..8b33e3a 100644 --- a/nanobot/heartbeat/service.py +++ b/nanobot/heartbeat/service.py @@ -78,7 +78,7 @@ class HeartbeatService: self._running = True self._task = asyncio.create_task(self._run_loop()) - logger.info(f"Heartbeat started (every {self.interval_s}s)") + logger.info("Heartbeat started (every {}s)", self.interval_s) def stop(self) -> None: """Stop the heartbeat service.""" @@ -118,7 +118,7 @@ class HeartbeatService: if HEARTBEAT_OK_TOKEN.replace("_", "") in response.upper().replace("_", ""): logger.info("Heartbeat: OK (no action needed)") else: - logger.info(f"Heartbeat: completed task") + logger.info("Heartbeat: completed task") except Exception as e: logger.error("Heartbeat execution failed: {}", e) diff --git a/nanobot/providers/transcription.py b/nanobot/providers/transcription.py index eb5969d..7a3c628 100644 --- a/nanobot/providers/transcription.py +++ b/nanobot/providers/transcription.py @@ -35,7 +35,7 @@ class GroqTranscriptionProvider: path = Path(file_path) if not path.exists(): - logger.error(f"Audio file not found: {file_path}") + logger.error("Audio file not found: {}", file_path) return "" try: diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 44dcecb..9c0c7de 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -110,7 +110,7 @@ class SessionManager: if legacy_path.exists(): import shutil shutil.move(str(legacy_path), str(path)) - logger.info(f"Migrated session {key} from legacy path") + logger.info("Migrated session {} from legacy path", key) if not path.exists(): return None From e17342ddfc812a629d54e4be28f7cb39a84be424 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 20 Feb 2026 08:03:24 +0000 Subject: [PATCH 36/36] fix: pass workspace to file tools in subagent --- nanobot/agent/subagent.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 767bc68..d87c61a 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -103,10 +103,10 @@ class SubagentManager: # Build subagent tools (no message tool, no spawn tool) tools = ToolRegistry() allowed_dir = self.workspace if self.restrict_to_workspace else None - tools.register(ReadFileTool(allowed_dir=allowed_dir)) - tools.register(WriteFileTool(allowed_dir=allowed_dir)) - tools.register(EditFileTool(allowed_dir=allowed_dir)) - tools.register(ListDirTool(allowed_dir=allowed_dir)) + tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) + tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) + tools.register(EditFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) + tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir)) tools.register(ExecTool( working_dir=str(self.workspace), timeout=self.exec_config.timeout,