diff --git a/README.md b/README.md
index dfe799a..68ad5a9 100644
--- a/README.md
+++ b/README.md
@@ -16,26 +16,33 @@
β‘οΈ 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,827 lines** (run `bash core_agent_lines.sh` to verify anytime)
## π’ 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.
-- **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).
-- **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.
+
+
+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!
-- **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!
+
+
## Key Features of nanobot:
πͺΆ **Ultra-Lightweight**: Just ~4,000 lines of core agent code β 99% smaller than Clawdbot.
@@ -571,6 +578,7 @@ Config file: `~/.nanobot/config.json`
> - **Groq** provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed.
> - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config.
> - **MiniMax (Mainland China)**: If your API key is from MiniMax's mainland China platform (minimaxi.com), set `"apiBase": "https://api.minimaxi.com/v1"` in your minimax provider config.
+> - **VolcEngine Coding Plan**: If you're on VolcEngine's coding plan, set `"apiBase": "https://ark.cn-beijing.volces.com/api/coding/v3"` in your volcengine provider config.
| Provider | Purpose | Get API Key |
|----------|---------|-------------|
@@ -583,7 +591,8 @@ 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) |
+| `siliconflow` | LLM (SiliconFlow/η‘
εΊζ΅ε¨) | [siliconflow.cn](https://siliconflow.cn) |
+| `volcengine` | LLM (VolcEngine/η«ε±±εΌζ) | [volcengine.com](https://www.volcengine.com) |
| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
| `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) |
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
@@ -744,6 +753,12 @@ Add MCP servers to your `config.json`:
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"]
+ },
+ "my-remote-mcp": {
+ "url": "https://example.com/mcp/",
+ "headers": {
+ "Authorization": "Bearer xxxxx"
+ }
}
}
}
@@ -755,7 +770,7 @@ Two transport modes are supported:
| Mode | Config | Example |
|------|--------|---------|
| **Stdio** | `command` + `args` | Local process via `npx` / `uvx` |
-| **HTTP** | `url` | Remote endpoint (`https://mcp.example.com/sse`) |
+| **HTTP** | `url` + `headers` (optional) | Remote endpoint (`https://mcp.example.com/sse`) |
MCP tools are automatically discovered and registered on startup. The LLM can use them alongside built-in tools β no extra configuration needed.
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/nanobot/agent/context.py b/nanobot/agent/context.py
index 458016e..67aad0c 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..3016d92 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
@@ -88,16 +89,17 @@ class AgentLoop:
self._mcp_servers = mcp_servers or {}
self._mcp_stack: AsyncExitStack | None = None
self._mcp_connected = False
+ self._consolidating: set[str] = set() # Session keys with consolidation in progress
self._register_default_tools()
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(
@@ -132,11 +134,11 @@ class AgentLoop:
await self._mcp_stack.__aenter__()
await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack)
- def _set_tool_context(self, channel: str, chat_id: str) -> None:
+ def _set_tool_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None:
"""Update context for all tools that need routing info."""
if message_tool := self.tools.get("message"):
if isinstance(message_tool, MessageTool):
- message_tool.set_context(channel, chat_id)
+ message_tool.set_context(channel, chat_id, message_id)
if spawn_tool := self.tools.get("spawn"):
if isinstance(spawn_tool, SpawnTool):
@@ -146,12 +148,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).
@@ -160,6 +184,7 @@ class AgentLoop:
iteration = 0
final_content = None
tools_used: list[str] = []
+ text_only_retried = False
while iteration < self.max_iterations:
iteration += 1
@@ -173,13 +198,19 @@ class AgentLoop:
)
if response.has_tool_calls:
+ if on_progress:
+ clean = self._strip_think(response.content)
+ if clean:
+ await on_progress(clean)
+ await on_progress(self._tool_hint(response.tool_calls))
+
tool_call_dicts = [
{
"id": tc.id,
"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
@@ -192,14 +223,24 @@ 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
)
- 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)
+ # Some models send an interim text response before tool calls.
+ # Give them one retry; don't forward the text to avoid duplicates.
+ if not tools_used and not text_only_retried and final_content:
+ text_only_retried = True
+ logger.debug("Interim text response (no tools used yet), retrying: {}", final_content[:80])
+ messages = self.context.add_assistant_message(
+ messages, response.content,
+ reasoning_content=response.reasoning_content,
+ )
+ final_content = None
+ continue
break
return final_content, tools_used
@@ -221,7 +262,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,
@@ -244,13 +285,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.
@@ -260,7 +307,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)
@@ -286,10 +333,18 @@ class AgentLoop:
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="π nanobot commands:\n/new β Start a new conversation\n/help β Show available commands")
- if len(session.messages) > self.memory_window:
- asyncio.create_task(self._consolidate_memory(session))
+ if len(session.messages) > self.memory_window and session.key not in self._consolidating:
+ self._consolidating.add(session.key)
- self._set_tool_context(msg.channel, msg.chat_id)
+ async def _consolidate_and_unlock():
+ try:
+ await self._consolidate_memory(session)
+ finally:
+ self._consolidating.discard(session.key)
+
+ asyncio.create_task(_consolidate_and_unlock())
+
+ self._set_tool_context(msg.channel, msg.chat_id, msg.metadata.get("message_id"))
initial_messages = self.context.build_messages(
history=session.get_history(max_messages=self.memory_window),
current_message=msg.content,
@@ -297,13 +352,22 @@ 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."
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,
@@ -324,7 +388,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:
@@ -338,7 +402,7 @@ class AgentLoop:
session_key = f"{origin_channel}:{origin_chat_id}"
session = self.sessions.get_or_create(session_key)
- self._set_tool_context(origin_channel, origin_chat_id)
+ self._set_tool_context(origin_channel, origin_chat_id, msg.metadata.get("message_id"))
initial_messages = self.context.build_messages(
history=session.get_history(max_messages=self.memory_window),
current_message=msg.content,
@@ -372,22 +436,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:
@@ -410,6 +474,14 @@ class AgentLoop:
## Conversation to Process
{conversation}
+**IMPORTANT**: Both values MUST be strings, not objects or arrays.
+
+Example:
+{{
+ "history_entry": "[2026-02-14 22:50] User asked about...",
+ "memory_update": "- Host: HARRYBOOK-T14P\n- Name: Nado"
+}}
+
Respond with ONLY valid JSON, no markdown fences."""
try:
@@ -428,12 +500,18 @@ 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"):
+ # Defensive: ensure entry is a string (LLM may return dict)
+ if not isinstance(entry, str):
+ entry = json.dumps(entry, ensure_ascii=False)
memory.append_history(entry)
if update := result.get("memory_update"):
+ # Defensive: ensure update is a string
+ if not isinstance(update, str):
+ update = json.dumps(update, ensure_ascii=False)
if update != current_memory:
memory.write_long_term(update)
@@ -441,9 +519,9 @@ 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(f"Memory consolidation failed: {e}")
+ logger.error("Memory consolidation failed: {}", e)
async def process_direct(
self,
@@ -451,6 +529,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 +539,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 +552,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/agent/subagent.py b/nanobot/agent/subagent.py
index 203836a..d87c61a 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,16 +97,16 @@ 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)
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,
@@ -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,8 +159,8 @@ 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}")
+ args_str = json.dumps(tool_call.arguments, ensure_ascii=False)
+ 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",
@@ -175,12 +175,12 @@ 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:
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(
@@ -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/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}"
diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py
index 1c8eac4..ad352bf 100644
--- a/nanobot/agent/tools/mcp.py
+++ b/nanobot/agent/tools/mcp.py
@@ -3,6 +3,7 @@
from contextlib import AsyncExitStack
from typing import Any
+import httpx
from loguru import logger
from nanobot.agent.tools.base import Tool
@@ -59,11 +60,22 @@ async def connect_mcp_servers(
read, write = await stack.enter_async_context(stdio_client(params))
elif cfg.url:
from mcp.client.streamable_http import streamable_http_client
- read, write, _ = await stack.enter_async_context(
- streamable_http_client(cfg.url)
- )
+ if cfg.headers:
+ http_client = await stack.enter_async_context(
+ httpx.AsyncClient(
+ headers=cfg.headers,
+ follow_redirects=True
+ )
+ )
+ read, write, _ = await stack.enter_async_context(
+ streamable_http_client(cfg.url, http_client=http_client)
+ )
+ else:
+ read, write, _ = await stack.enter_async_context(
+ 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 +85,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(f"MCP server '{name}': failed to connect: {e}")
+ logger.error("MCP server '{}': failed to connect: {}", name, e)
diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py
index 3853725..10947c4 100644
--- a/nanobot/agent/tools/message.py
+++ b/nanobot/agent/tools/message.py
@@ -13,16 +13,19 @@ class MessageTool(Tool):
self,
send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None,
default_channel: str = "",
- default_chat_id: str = ""
+ default_chat_id: str = "",
+ default_message_id: str | None = None
):
self._send_callback = send_callback
self._default_channel = default_channel
self._default_chat_id = default_chat_id
+ self._default_message_id = default_message_id
- def set_context(self, channel: str, chat_id: str) -> None:
+ def set_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None:
"""Set the current message context."""
self._default_channel = channel
self._default_chat_id = chat_id
+ self._default_message_id = message_id
def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None:
"""Set the callback for sending messages."""
@@ -67,11 +70,13 @@ class MessageTool(Tool):
content: str,
channel: str | None = None,
chat_id: str | None = None,
+ message_id: str | None = None,
media: list[str] | None = None,
**kwargs: Any
) -> str:
channel = channel or self._default_channel
chat_id = chat_id or self._default_chat_id
+ message_id = message_id or self._default_message_id
if not channel or not chat_id:
return "Error: No target channel/chat specified"
@@ -83,7 +88,10 @@ class MessageTool(Tool):
channel=channel,
chat_id=chat_id,
content=content,
- media=media or []
+ media=media or [],
+ metadata={
+ "message_id": message_id,
+ }
)
try:
diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py
index 18eff64..e3592a7 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
@@ -81,6 +82,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 = []
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/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."""
diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py
index 4a8cdd9..b7263b3 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:
@@ -208,7 +208,7 @@ class DingTalkChannel(BaseChannel):
"msgParam": json.dumps({
"text": msg.content,
"title": "Nanobot Reply",
- }),
+ }, ensure_ascii=False),
}
if not self._http:
@@ -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}")
+ logger.debug("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..8baecbf 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)
@@ -94,14 +94,14 @@ 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()
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..1b6f46b 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:
@@ -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 bc4a2b8..a8ca1fa 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
@@ -17,6 +18,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,
@@ -151,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)
@@ -172,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:
@@ -189,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}")
+ logger.debug("Added {} reaction to message {}", emoji_type, 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:
"""
@@ -263,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",
@@ -284,50 +288,128 @@ class FeishuChannel(BaseChannel):
return elements or [{"tag": "markdown", "content": content}]
- async def send(self, msg: OutboundMessage) -> None:
- """Send a message through Feishu."""
- if not self._client:
- logger.warning("Feishu client not initialized")
- return
-
+ _IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"}
+ _AUDIO_EXTS = {".opus"}
+ _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."""
+ 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("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)
+ return None
+ except Exception as e:
+ logger.error("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."""
+ 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("Uploaded file {}: {}", file_name, file_key)
+ return file_key
+ else:
+ logger.error("Failed to upload file: code={}, msg={}", response.code, response.msg)
+ return None
+ except Exception as 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:
+ """Send a single message (text/image/file/interactive) synchronously."""
try:
- # 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")
+ .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 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()
)
- else:
- logger.debug(f"Feishu message sent to {msg.chat_id}")
-
+ return False
+ logger.debug("Feishu {} message sent to {}", msg_type, receive_id)
+ return True
except Exception as e:
- logger.error(f"Error sending Feishu message: {e}")
+ logger.error("Error sending Feishu {} message: {}", msg_type, e)
+ return False
+
+ async def send(self, msg: OutboundMessage) -> None:
+ """Send a message through Feishu, including media (images/files) if present."""
+ if not self._client:
+ logger.warning("Feishu client not initialized")
+ return
+
+ try:
+ receive_id_type = "chat_id" if msg.chat_id.startswith("oc_") else "open_id"
+ loop = asyncio.get_running_loop()
+
+ for file_path in msg.media:
+ if not os.path.isfile(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:
+ 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}, ensure_ascii=False),
+ )
+ 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}, ensure_ascii=False),
+ )
+
+ if msg.content and msg.content.strip():
+ card = {"config": {"wide_screen_mode": True}, "elements": self._build_card_elements(msg.content)}
+ await loop.run_in_executor(
+ None, self._send_message_sync,
+ receive_id_type, msg.chat_id, "interactive", json.dumps(card, ensure_ascii=False),
+ )
+
+ except Exception as e:
+ logger.error("Error sending Feishu message: {}", e)
def _on_message_sync(self, data: "P2ImMessageReceiveV1") -> None:
"""
@@ -399,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..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,14 +135,14 @@ 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."""
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."""
@@ -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,9 +178,9 @@ 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(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,9 +198,9 @@ 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}")
+ logger.warning("Unknown channel: {}", msg.channel)
except asyncio.TimeoutError:
continue
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..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)
@@ -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..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,9 +53,9 @@ 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(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..6cd98e7 100644
--- a/nanobot/channels/telegram.py
+++ b/nanobot/channels/telegram.py
@@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio
import re
from loguru import logger
-from telegram import BotCommand, Update
+from telegram import BotCommand, Update, ReplyParameters
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
from telegram.request import HTTPXRequest
@@ -146,7 +146,7 @@ class TelegramChannel(BaseChannel):
# Add command handlers
self._app.add_handler(CommandHandler("start", self._on_start))
self._app.add_handler(CommandHandler("new", self._forward_command))
- self._app.add_handler(CommandHandler("help", self._forward_command))
+ self._app.add_handler(CommandHandler("help", self._on_help))
# Add message handler for text, photos, voice, documents
self._app.add_handler(
@@ -165,13 +165,13 @@ 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)
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(
@@ -221,9 +221,18 @@ 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
+ reply_params = None
+ if self.config.reply_to_message:
+ reply_to_message_id = msg.metadata.get("message_id")
+ if reply_to_message_id:
+ reply_params = ReplyParameters(
+ message_id=reply_to_message_id,
+ allow_sending_without_reply=True
+ )
+
# Send media files
for media_path in (msg.media or []):
try:
@@ -235,37 +244,64 @@ class TelegramChannel(BaseChannel):
}.get(media_type, self._app.bot.send_document)
param = "photo" if media_type == "photo" else media_type if media_type in ("voice", "audio") else "document"
with open(media_path, 'rb') as f:
- await sender(chat_id=chat_id, **{param: f})
+ await sender(
+ chat_id=chat_id,
+ **{param: f},
+ reply_parameters=reply_params
+ )
except Exception as e:
filename = media_path.rsplit("/", 1)[-1]
- logger.error(f"Failed to send media {media_path}: {e}")
- await self._app.bot.send_message(chat_id=chat_id, text=f"[Failed to send: {filename}]")
+ 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}]",
+ reply_parameters=reply_params
+ )
# Send text content
if msg.content and msg.content != "[empty message]":
for chunk in _split_message(msg.content):
try:
html = _markdown_to_telegram_html(chunk)
- await self._app.bot.send_message(chat_id=chat_id, text=html, parse_mode="HTML")
+ await self._app.bot.send_message(
+ chat_id=chat_id,
+ text=html,
+ parse_mode="HTML",
+ reply_parameters=reply_params
+ )
except Exception as e:
- logger.warning(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)
+ await self._app.bot.send_message(
+ chat_id=chat_id,
+ text=chunk,
+ reply_parameters=reply_params
+ )
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."""
if not update.message or not update.effective_user:
return
-
+
user = update.effective_user
await update.message.reply_text(
f"π Hi {user.first_name}! I'm nanobot.\n\n"
"Send me a message and I'll respond!\n"
"Type /help to see available commands."
)
-
+
+ async def _on_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Handle /help command, bypassing ACL so all users can access it."""
+ if not update.message:
+ return
+ await update.message.reply_text(
+ "π nanobot commands:\n"
+ "/new β Start a new conversation\n"
+ "/help β Show available commands"
+ )
+
@staticmethod
def _sender_id(user) -> str:
"""Build sender_id with username for allowlist matching."""
@@ -344,21 +380,21 @@ 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(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 +437,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..f5fb521 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
@@ -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...")
@@ -87,16 +87,16 @@ 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}")
+ 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")
@@ -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
@@ -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/cli/commands.py b/nanobot/cli/commands.py
index 8e17139..a135349 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
@@ -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()
@@ -802,15 +805,19 @@ def cron_add(
store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path)
- job = service.add_job(
- name=name,
- schedule=schedule,
- message=message,
- deliver=deliver,
- to=to,
- channel=channel,
- )
-
+ try:
+ job = service.add_job(
+ name=name,
+ schedule=schedule,
+ message=message,
+ deliver=deliver,
+ to=to,
+ channel=channel,
+ )
+ except ValueError as e:
+ console.print(f"[red]Error: {e}[/red]")
+ raise typer.Exit(1) from e
+
console.print(f"[green]β[/green] Added job '{job.name}' ({job.id})")
@@ -857,17 +864,56 @@ def cron_run(
force: bool = typer.Option(False, "--force", "-f", help="Run even if disabled"),
):
"""Manually run a job."""
- from nanobot.config.loader import get_data_dir
+ from loguru import logger
+ from nanobot.config.loader import load_config, get_data_dir
from nanobot.cron.service import CronService
-
+ from nanobot.cron.types import CronJob
+ from nanobot.bus.queue import MessageBus
+ from nanobot.agent.loop import AgentLoop
+ logger.disable("nanobot")
+
+ config = load_config()
+ provider = _make_provider(config)
+ bus = MessageBus()
+ agent_loop = AgentLoop(
+ bus=bus,
+ provider=provider,
+ workspace=config.workspace_path,
+ model=config.agents.defaults.model,
+ temperature=config.agents.defaults.temperature,
+ max_tokens=config.agents.defaults.max_tokens,
+ max_iterations=config.agents.defaults.max_tool_iterations,
+ memory_window=config.agents.defaults.memory_window,
+ brave_api_key=config.tools.web.search.api_key or None,
+ exec_config=config.tools.exec,
+ restrict_to_workspace=config.tools.restrict_to_workspace,
+ mcp_servers=config.tools.mcp_servers,
+ )
+
store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path)
-
+
+ result_holder = []
+
+ async def on_job(job: CronJob) -> str | None:
+ response = await agent_loop.process_direct(
+ job.payload.message,
+ session_key=f"cron:{job.id}",
+ channel=job.payload.channel or "cli",
+ chat_id=job.payload.to or "direct",
+ )
+ result_holder.append(response)
+ return response
+
+ service.on_job = on_job
+
async def run():
return await service.run_job(job_id, force=force)
-
+
if asyncio.run(run()):
- console.print(f"[green]β[/green] Job executed")
+ console.print("[green]β[/green] Job executed")
+ if result_holder:
+ _print_agent_response(result_holder[0], render_markdown=True)
else:
console.print(f"[red]Failed to run job {job_id}[/red]")
diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py
index 560c1f5..c789efd 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)
@@ -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/config/schema.py b/nanobot/config/schema.py
index ce9634c..966d11d 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -28,6 +28,7 @@ class TelegramConfig(Base):
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"
+ reply_to_message: bool = False # If true, bot replies quote the original message
class FeishuConfig(Base):
@@ -220,6 +221,7 @@ class ProvidersConfig(Base):
minimax: ProviderConfig = Field(default_factory=ProviderConfig)
aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway
siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (η‘
εΊζ΅ε¨) API gateway
+ volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (η«ε±±εΌζ) API gateway
openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth)
github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth)
@@ -257,6 +259,7 @@ class MCPServerConfig(Base):
args: list[str] = Field(default_factory=list) # Stdio: command arguments
env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars
url: str = "" # HTTP: streamable HTTP endpoint URL
+ headers: dict[str, str] = Field(default_factory=dict) # HTTP: Custom HTTP Headers
class ToolsConfig(Base):
@@ -287,11 +290,25 @@ 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 _kw_matches(kw: str) -> bool:
+ kw = kw.lower()
+ return kw in model_lower or kw.replace("-", "_") in model_normalized
+
+ # 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 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(kw in model_lower 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/cron/service.py b/nanobot/cron/service.py
index 14666e8..6889a10 100644
--- a/nanobot/cron/service.py
+++ b/nanobot/cron/service.py
@@ -45,6 +45,20 @@ def _compute_next_run(schedule: CronSchedule, now_ms: int) -> int | None:
return None
+def _validate_schedule_for_add(schedule: CronSchedule) -> None:
+ """Validate schedule fields that would otherwise create non-runnable jobs."""
+ if schedule.tz and schedule.kind != "cron":
+ raise ValueError("tz can only be used with cron schedules")
+
+ if schedule.kind == "cron" and schedule.tz:
+ try:
+ from zoneinfo import ZoneInfo
+
+ ZoneInfo(schedule.tz)
+ except Exception:
+ raise ValueError(f"unknown timezone '{schedule.tz}'") from None
+
+
class CronService:
"""Service for managing and executing scheduled jobs."""
@@ -66,7 +80,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(
@@ -99,7 +113,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()
@@ -148,7 +162,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."""
@@ -157,7 +171,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 +236,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,12 +245,12 @@ 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"
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()
@@ -272,6 +286,7 @@ class CronService:
) -> CronJob:
"""Add a new job."""
store = self._load_store()
+ _validate_schedule_for_add(schedule)
now = _now_ms()
job = CronJob(
@@ -296,7 +311,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 +324,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 221ed27..3c1a6aa 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
@@ -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."""
@@ -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."""
@@ -118,10 +118,10 @@ 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(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/litellm_provider.py b/nanobot/providers/litellm_provider.py
index 58acf95..4fe44f7 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -94,11 +94,55 @@ 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 _supports_cache_control(self, model: str) -> bool:
+ """Return True when the provider supports cache_control on content blocks."""
+ if self._gateway is not None:
+ return False
+ spec = find_by_model(model)
+ return spec is not None and spec.supports_prompt_caching
+
+ def _apply_cache_control(
+ self,
+ messages: list[dict[str, Any]],
+ tools: list[dict[str, Any]] | None,
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]] | None]:
+ """Return copies of messages and tools with cache_control injected."""
+ new_messages = []
+ for msg in messages:
+ if msg.get("role") == "system":
+ content = msg["content"]
+ if isinstance(content, str):
+ new_content = [{"type": "text", "text": content, "cache_control": {"type": "ephemeral"}}]
+ else:
+ new_content = list(content)
+ new_content[-1] = {**new_content[-1], "cache_control": {"type": "ephemeral"}}
+ new_messages.append({**msg, "content": new_content})
+ else:
+ new_messages.append(msg)
+
+ new_tools = tools
+ if tools:
+ new_tools = list(tools)
+ new_tools[-1] = {**new_tools[-1], "cache_control": {"type": "ephemeral"}}
+
+ return new_messages, new_tools
+
def _apply_model_overrides(self, model: str, kwargs: dict[str, Any]) -> None:
"""Apply model-specific parameter overrides from the registry."""
model_lower = model.lower()
@@ -148,8 +192,12 @@ class LiteLLMProvider(LLMProvider):
Returns:
LLMResponse with content and/or tool calls.
"""
- model = self._resolve_model(model or self.default_model)
-
+ original_model = model or self.default_model
+ model = self._resolve_model(original_model)
+
+ if self._supports_cache_control(original_model):
+ messages, tools = self._apply_cache_control(messages, tools)
+
# Clamp max_tokens to at least 1 β negative or zero values cause
# LiteLLM to reject the request with "max_tokens must be at least 1".
max_tokens = max(1, max_tokens)
diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py
index 5067438..fa28593 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
@@ -176,7 +176,7 @@ def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[st
if role == "tool":
call_id, _ = _split_tool_call_id(msg.get("tool_call_id"))
- output_text = content if isinstance(content, str) else json.dumps(content)
+ output_text = content if isinstance(content, str) else json.dumps(content, ensure_ascii=False)
input_items.append(
{
"type": "function_call_output",
diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py
index 49b735c..445d977 100644
--- a/nanobot/providers/registry.py
+++ b/nanobot/providers/registry.py
@@ -57,6 +57,9 @@ class ProviderSpec:
# Direct providers bypass LiteLLM entirely (e.g., CustomProvider)
is_direct: bool = False
+ # Provider supports cache_control on content blocks (e.g. Anthropic prompt caching)
+ supports_prompt_caching: bool = False
+
@property
def label(self) -> str:
return self.display_name or self.name.title()
@@ -137,6 +140,24 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
model_overrides=(),
),
+ # VolcEngine (η«ε±±εΌζ): OpenAI-compatible gateway
+ ProviderSpec(
+ name="volcengine",
+ keywords=("volcengine", "volces", "ark"),
+ env_key="OPENAI_API_KEY",
+ display_name="VolcEngine",
+ litellm_prefix="openai",
+ skip_prefixes=(),
+ env_extras=(),
+ is_gateway=True,
+ is_local=False,
+ detect_by_key_prefix="",
+ detect_by_base_keyword="volces",
+ default_api_base="https://ark.cn-beijing.volces.com/api/v3",
+ strip_model_prefix=False,
+ model_overrides=(),
+ ),
+
# === Standard providers (matched by model-name keywords) ===============
# Anthropic: LiteLLM recognizes "claude-*" natively, no prefix needed.
@@ -155,6 +176,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
default_api_base="",
strip_model_prefix=False,
model_overrides=(),
+ supports_prompt_caching=True,
),
# OpenAI: LiteLLM recognizes "gpt-*" natively, no prefix needed.
@@ -384,10 +406,18 @@ 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()
- for spec in PROVIDERS:
- if spec.is_gateway or spec.is_local:
- continue
- if any(kw in model_lower for kw in spec.keywords):
+ 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 β 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 std_specs:
+ if any(kw in model_lower or kw.replace("-", "_") in model_normalized for kw in spec.keywords):
return spec
return None
diff --git a/nanobot/providers/transcription.py b/nanobot/providers/transcription.py
index 8ce909b..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:
@@ -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..9c1e427 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
@@ -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:
@@ -144,14 +144,14 @@ 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:
"""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,9 @@ 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 +181,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)
diff --git a/pyproject.toml b/pyproject.toml
index 6261653..64a884d 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"}
@@ -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",
]
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"
diff --git a/tests/test_cron_commands.py b/tests/test_cron_commands.py
new file mode 100644
index 0000000..bce1ef5
--- /dev/null
+++ b/tests/test_cron_commands.py
@@ -0,0 +1,29 @@
+from typer.testing import CliRunner
+
+from nanobot.cli.commands import app
+
+runner = CliRunner()
+
+
+def test_cron_add_rejects_invalid_timezone(monkeypatch, tmp_path) -> None:
+ monkeypatch.setattr("nanobot.config.loader.get_data_dir", lambda: tmp_path)
+
+ result = runner.invoke(
+ app,
+ [
+ "cron",
+ "add",
+ "--name",
+ "demo",
+ "--message",
+ "hello",
+ "--cron",
+ "0 9 * * *",
+ "--tz",
+ "America/Vancovuer",
+ ],
+ )
+
+ assert result.exit_code == 1
+ assert "Error: unknown timezone 'America/Vancovuer'" in result.stdout
+ assert not (tmp_path / "cron" / "jobs.json").exists()
diff --git a/tests/test_cron_service.py b/tests/test_cron_service.py
new file mode 100644
index 0000000..07e990a
--- /dev/null
+++ b/tests/test_cron_service.py
@@ -0,0 +1,30 @@
+import pytest
+
+from nanobot.cron.service import CronService
+from nanobot.cron.types import CronSchedule
+
+
+def test_add_job_rejects_unknown_timezone(tmp_path) -> None:
+ service = CronService(tmp_path / "cron" / "jobs.json")
+
+ with pytest.raises(ValueError, match="unknown timezone 'America/Vancovuer'"):
+ service.add_job(
+ name="tz typo",
+ schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="America/Vancovuer"),
+ message="hello",
+ )
+
+ assert service.list_jobs(include_disabled=True) == []
+
+
+def test_add_job_accepts_valid_timezone(tmp_path) -> None:
+ service = CronService(tmp_path / "cron" / "jobs.json")
+
+ job = service.add_job(
+ name="tz ok",
+ schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="America/Vancouver"),
+ message="hello",
+ )
+
+ assert job.schedule.tz == "America/Vancouver"
+ assert job.state.next_run_at_ms is not None