From 80219baf255d2f75b15edac616f24b7b0025ded1 Mon Sep 17 00:00:00 2001 From: Tink Date: Sun, 1 Mar 2026 10:53:45 +0800 Subject: [PATCH 01/91] feat(api): add OpenAI-compatible endpoint with x-session-key isolation --- examples/curl.txt | 96 ++++ nanobot/agent/context.py | 36 +- nanobot/agent/loop.py | 80 ++- nanobot/api/__init__.py | 1 + nanobot/api/server.py | 222 ++++++++ nanobot/cli/commands.py | 77 +++ pyproject.toml | 4 + tests/test_consolidate_offset.py | 14 +- tests/test_openai_api.py | 883 +++++++++++++++++++++++++++++++ 9 files changed, 1387 insertions(+), 26 deletions(-) create mode 100644 examples/curl.txt create mode 100644 nanobot/api/__init__.py create mode 100644 nanobot/api/server.py create mode 100644 tests/test_openai_api.py diff --git a/examples/curl.txt b/examples/curl.txt new file mode 100644 index 00000000..70dc4dfe --- /dev/null +++ b/examples/curl.txt @@ -0,0 +1,96 @@ +# ============================================================================= +# nanobot OpenAI-Compatible API — curl examples +# ============================================================================= +# +# Prerequisites: +# pip install nanobot-ai[api] # installs aiohttp +# nanobot serve --port 8900 # start the API server +# +# The x-session-key header is REQUIRED for every request. +# Convention: +# Private chat: wx:dm:{sender_id} +# Group @: wx:group:{group_id}:user:{sender_id} +# ============================================================================= + +# --- 1. Basic chat completion (private chat) --- + +curl -X POST http://localhost:8900/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "x-session-key: wx:dm:user_alice" \ + -d '{ + "model": "nanobot", + "messages": [ + {"role": "user", "content": "Hello, who are you?"} + ] + }' + +# --- 2. Follow-up in the same session (context is remembered) --- + +curl -X POST http://localhost:8900/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "x-session-key: wx:dm:user_alice" \ + -d '{ + "model": "nanobot", + "messages": [ + {"role": "user", "content": "What did I just ask you?"} + ] + }' + +# --- 3. Different user — isolated session --- + +curl -X POST http://localhost:8900/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "x-session-key: wx:dm:user_bob" \ + -d '{ + "model": "nanobot", + "messages": [ + {"role": "user", "content": "What did I just ask you?"} + ] + }' +# ↑ Bob gets a fresh context — he never asked anything before. + +# --- 4. Group chat — per-user session within a group --- + +curl -X POST http://localhost:8900/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "x-session-key: wx:group:group_abc:user:user_alice" \ + -d '{ + "model": "nanobot", + "messages": [ + {"role": "user", "content": "Summarize our discussion"} + ] + }' + +# --- 5. List available models --- + +curl http://localhost:8900/v1/models + +# --- 6. Health check --- + +curl http://localhost:8900/health + +# --- 7. Missing header — expect 400 --- + +curl -X POST http://localhost:8900/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "nanobot", + "messages": [ + {"role": "user", "content": "hello"} + ] + }' +# ↑ Returns: {"error": {"message": "Missing required header: x-session-key", ...}} + +# --- 8. Stream not yet supported — expect 400 --- + +curl -X POST http://localhost:8900/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "x-session-key: wx:dm:user_alice" \ + -d '{ + "model": "nanobot", + "messages": [ + {"role": "user", "content": "hello"} + ], + "stream": true + }' +# ↑ Returns: {"error": {"message": "stream=true is not supported yet...", ...}} diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index be0ec599..3665d7f3 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -23,15 +23,25 @@ class ContextBuilder: self.memory = MemoryStore(workspace) self.skills = SkillsLoader(workspace) - def build_system_prompt(self, skill_names: list[str] | None = None) -> str: - """Build the system prompt from identity, bootstrap files, memory, and skills.""" - parts = [self._get_identity()] + def build_system_prompt( + self, + skill_names: list[str] | None = None, + memory_store: "MemoryStore | None" = None, + ) -> str: + """Build the system prompt from identity, bootstrap files, memory, and skills. + + Args: + memory_store: If provided, use this MemoryStore instead of the default + workspace-level one. Used for per-session memory isolation. + """ + parts = [self._get_identity(memory_store=memory_store)] bootstrap = self._load_bootstrap_files() if bootstrap: parts.append(bootstrap) - memory = self.memory.get_memory_context() + store = memory_store or self.memory + memory = store.get_memory_context() if memory: parts.append(f"# Memory\n\n{memory}") @@ -52,12 +62,19 @@ Skills with available="false" need dependencies installed first - you can try in return "\n\n---\n\n".join(parts) - def _get_identity(self) -> str: + def _get_identity(self, memory_store: "MemoryStore | None" = None) -> str: """Get the core identity section.""" workspace_path = str(self.workspace.expanduser().resolve()) system = platform.system() runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}" - + + if memory_store is not None: + mem_path = str(memory_store.memory_file) + hist_path = str(memory_store.history_file) + else: + mem_path = f"{workspace_path}/memory/MEMORY.md" + hist_path = f"{workspace_path}/memory/HISTORY.md" + return f"""# nanobot 🐈 You are nanobot, a helpful AI assistant. @@ -67,8 +84,8 @@ You are nanobot, a helpful AI assistant. ## Workspace Your workspace is at: {workspace_path} -- Long-term memory: {workspace_path}/memory/MEMORY.md (write important facts here) -- History log: {workspace_path}/memory/HISTORY.md (grep-searchable). Each entry starts with [YYYY-MM-DD HH:MM]. +- Long-term memory: {mem_path} (write important facts here) +- History log: {hist_path} (grep-searchable). Each entry starts with [YYYY-MM-DD HH:MM]. - Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md ## nanobot Guidelines @@ -110,10 +127,11 @@ Reply directly with text for conversations. Only use the 'message' tool to send media: list[str] | None = None, channel: str | None = None, chat_id: str | None = None, + memory_store: "MemoryStore | None" = None, ) -> list[dict[str, Any]]: """Build the complete message list for an LLM call.""" return [ - {"role": "system", "content": self.build_system_prompt(skill_names)}, + {"role": "system", "content": self.build_system_prompt(skill_names, memory_store=memory_store)}, *history, {"role": "user", "content": self._build_runtime_context(channel, chat_id)}, {"role": "user", "content": self._build_user_content(current_message, media)}, diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index b605ae4a..6a0d24f2 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -174,6 +174,7 @@ class AgentLoop: self, initial_messages: list[dict], on_progress: Callable[..., Awaitable[None]] | None = None, + disabled_tools: set[str] | None = None, ) -> tuple[str | None, list[str], list[dict]]: """Run the agent iteration loop. Returns (final_content, tools_used, messages).""" messages = initial_messages @@ -181,12 +182,19 @@ class AgentLoop: final_content = None tools_used: list[str] = [] + # Build tool definitions, filtering out disabled tools + if disabled_tools: + tool_defs = [d for d in self.tools.get_definitions() + if d.get("function", {}).get("name") not in disabled_tools] + else: + tool_defs = self.tools.get_definitions() + while iteration < self.max_iterations: iteration += 1 response = await self.provider.chat( messages=messages, - tools=self.tools.get_definitions(), + tools=tool_defs, model=self.model, temperature=self.temperature, max_tokens=self.max_tokens, @@ -219,7 +227,10 @@ class AgentLoop: tools_used.append(tool_call.name) args_str = json.dumps(tool_call.arguments, ensure_ascii=False) logger.info("Tool call: {}({})", tool_call.name, args_str[:200]) - result = await self.tools.execute(tool_call.name, tool_call.arguments) + if disabled_tools and tool_call.name in disabled_tools: + result = f"Error: Tool '{tool_call.name}' is not available in this mode." + else: + 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 ) @@ -322,6 +333,8 @@ class AgentLoop: msg: InboundMessage, session_key: str | None = None, on_progress: Callable[[str], Awaitable[None]] | None = None, + memory_store: MemoryStore | None = None, + disabled_tools: set[str] | None = None, ) -> OutboundMessage | None: """Process a single inbound message and return the response.""" # System messages: parse origin from chat_id ("channel:chat_id") @@ -336,8 +349,11 @@ class AgentLoop: messages = self.context.build_messages( history=history, current_message=msg.content, channel=channel, chat_id=chat_id, + memory_store=memory_store, + ) + final_content, _, all_msgs = await self._run_agent_loop( + messages, disabled_tools=disabled_tools, ) - final_content, _, all_msgs = await self._run_agent_loop(messages) self._save_turn(session, all_msgs, 1 + len(history)) self.sessions.save(session) return OutboundMessage(channel=channel, chat_id=chat_id, @@ -360,7 +376,9 @@ class AgentLoop: if snapshot: temp = Session(key=session.key) temp.messages = list(snapshot) - if not await self._consolidate_memory(temp, archive_all=True): + if not await self._consolidate_memory( + temp, archive_all=True, memory_store=memory_store, + ): return OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content="Memory archival failed, session not cleared. Please try again.", @@ -393,7 +411,9 @@ class AgentLoop: async def _consolidate_and_unlock(): try: async with lock: - await self._consolidate_memory(session) + await self._consolidate_memory( + session, memory_store=memory_store, + ) finally: self._consolidating.discard(session.key) if not lock.locked(): @@ -416,6 +436,7 @@ class AgentLoop: current_message=msg.content, media=msg.media if msg.media else None, channel=msg.channel, chat_id=msg.chat_id, + memory_store=memory_store, ) async def _bus_progress(content: str, *, tool_hint: bool = False) -> None: @@ -428,6 +449,7 @@ class AgentLoop: final_content, _, all_msgs = await self._run_agent_loop( initial_messages, on_progress=on_progress or _bus_progress, + disabled_tools=disabled_tools, ) if final_content is None: @@ -470,9 +492,30 @@ class AgentLoop: session.messages.append(entry) session.updated_at = datetime.now() - async def _consolidate_memory(self, session, archive_all: bool = False) -> bool: - """Delegate to MemoryStore.consolidate(). Returns True on success.""" - return await MemoryStore(self.workspace).consolidate( + def _isolated_memory_store(self, session_key: str) -> MemoryStore: + """Return a per-session-key MemoryStore for multi-tenant isolation.""" + from nanobot.utils.helpers import safe_filename + safe_key = safe_filename(session_key.replace(":", "_")) + memory_dir = self.workspace / "sessions" / safe_key / "memory" + memory_dir.mkdir(parents=True, exist_ok=True) + store = MemoryStore.__new__(MemoryStore) + store.memory_dir = memory_dir + store.memory_file = memory_dir / "MEMORY.md" + store.history_file = memory_dir / "HISTORY.md" + return store + + async def _consolidate_memory( + self, session, archive_all: bool = False, + memory_store: MemoryStore | None = None, + ) -> bool: + """Delegate to MemoryStore.consolidate(). Returns True on success. + + Args: + memory_store: If provided, consolidate into this store instead of + the default workspace-level one. + """ + store = memory_store or MemoryStore(self.workspace) + return await store.consolidate( session, self.provider, self.model, archive_all=archive_all, memory_window=self.memory_window, ) @@ -484,9 +527,26 @@ class AgentLoop: channel: str = "cli", chat_id: str = "direct", on_progress: Callable[[str], Awaitable[None]] | None = None, + isolate_memory: bool = False, + disabled_tools: set[str] | None = None, ) -> str: - """Process a message directly (for CLI or cron usage).""" + """Process a message directly (for CLI or cron usage). + + Args: + isolate_memory: When True, use a per-session-key memory directory + instead of the shared workspace memory. This prevents context + leakage between different session keys in multi-tenant (API) mode. + disabled_tools: Tool names to exclude from the LLM tool list and + reject at execution time. Use to block filesystem access in + multi-tenant API mode. + """ await self._connect_mcp() + memory_store: MemoryStore | None = None + if isolate_memory: + memory_store = self._isolated_memory_store(session_key) msg = InboundMessage(channel=channel, sender_id="user", chat_id=chat_id, content=content) - response = await self._process_message(msg, session_key=session_key, on_progress=on_progress) + response = await self._process_message( + msg, session_key=session_key, on_progress=on_progress, + memory_store=memory_store, disabled_tools=disabled_tools, + ) return response.content if response else "" diff --git a/nanobot/api/__init__.py b/nanobot/api/__init__.py new file mode 100644 index 00000000..f0c504cc --- /dev/null +++ b/nanobot/api/__init__.py @@ -0,0 +1 @@ +"""OpenAI-compatible HTTP API for nanobot.""" diff --git a/nanobot/api/server.py b/nanobot/api/server.py new file mode 100644 index 00000000..a3077537 --- /dev/null +++ b/nanobot/api/server.py @@ -0,0 +1,222 @@ +"""OpenAI-compatible HTTP API server for nanobot. + +Provides /v1/chat/completions and /v1/models endpoints. +Session isolation is enforced via the x-session-key request header. +""" + +from __future__ import annotations + +import asyncio +import time +import uuid +from typing import Any + +from aiohttp import web +from loguru import logger + +# Tools that must NOT run in multi-tenant API mode. +# Filesystem tools allow the LLM to read/write the shared workspace (including +# global MEMORY.md), and exec allows shell commands that can bypass filesystem +# restrictions (e.g. `cat ~/.nanobot/workspace/memory/MEMORY.md`). +_API_DISABLED_TOOLS: set[str] = { + "read_file", "write_file", "edit_file", "list_dir", "exec", +} + + +# --------------------------------------------------------------------------- +# Per-session-key lock manager +# --------------------------------------------------------------------------- + +class _SessionLocks: + """Manages one asyncio.Lock per session key for serial execution.""" + + def __init__(self) -> None: + self._locks: dict[str, asyncio.Lock] = {} + self._ref: dict[str, int] = {} # reference count for cleanup + + def acquire(self, key: str) -> asyncio.Lock: + if key not in self._locks: + self._locks[key] = asyncio.Lock() + self._ref[key] = 0 + self._ref[key] += 1 + return self._locks[key] + + def release(self, key: str) -> None: + self._ref[key] -= 1 + if self._ref[key] <= 0: + self._locks.pop(key, None) + self._ref.pop(key, None) + + +# --------------------------------------------------------------------------- +# Response helpers +# --------------------------------------------------------------------------- + +def _error_json(status: int, message: str, err_type: str = "invalid_request_error") -> web.Response: + return web.json_response( + {"error": {"message": message, "type": err_type, "code": status}}, + status=status, + ) + + +def _chat_completion_response(content: str, model: str) -> dict[str, Any]: + return { + "id": f"chatcmpl-{uuid.uuid4().hex[:12]}", + "object": "chat.completion", + "created": int(time.time()), + "model": model, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": content}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, + } + + +# --------------------------------------------------------------------------- +# Route handlers +# --------------------------------------------------------------------------- + +async def handle_chat_completions(request: web.Request) -> web.Response: + """POST /v1/chat/completions""" + + # --- x-session-key validation --- + session_key = request.headers.get("x-session-key", "").strip() + if not session_key: + return _error_json(400, "Missing required header: x-session-key") + + # --- Parse body --- + try: + body = await request.json() + except Exception: + return _error_json(400, "Invalid JSON body") + + messages = body.get("messages") + if not messages or not isinstance(messages, list): + return _error_json(400, "messages field is required and must be a non-empty array") + + # Stream not yet supported + if body.get("stream", False): + return _error_json(400, "stream=true is not supported yet. Set stream=false or omit it.") + + # Extract last user message — nanobot manages its own multi-turn history + user_content = None + for msg in reversed(messages): + if msg.get("role") == "user": + user_content = msg.get("content", "") + break + if user_content is None: + return _error_json(400, "messages must contain at least one user message") + if isinstance(user_content, list): + # Multi-modal content array — extract text parts + user_content = " ".join( + part.get("text", "") for part in user_content if part.get("type") == "text" + ) + + agent_loop = request.app["agent_loop"] + timeout_s: float = request.app.get("request_timeout", 120.0) + model_name: str = body.get("model") or request.app.get("model_name", "nanobot") + locks: _SessionLocks = request.app["session_locks"] + + safe_key = session_key[:32] + ("…" if len(session_key) > 32 else "") + logger.info("API request session_key={} content={}", safe_key, user_content[:80]) + + _FALLBACK = "I've completed processing but have no response to give." + + lock = locks.acquire(session_key) + try: + async with lock: + try: + response_text = await asyncio.wait_for( + agent_loop.process_direct( + content=user_content, + session_key=session_key, + channel="api", + chat_id=session_key, + isolate_memory=True, + disabled_tools=_API_DISABLED_TOOLS, + ), + timeout=timeout_s, + ) + + if not response_text or not response_text.strip(): + logger.warning("Empty response for session {}, retrying", safe_key) + response_text = await asyncio.wait_for( + agent_loop.process_direct( + content=user_content, + session_key=session_key, + channel="api", + chat_id=session_key, + isolate_memory=True, + disabled_tools=_API_DISABLED_TOOLS, + ), + timeout=timeout_s, + ) + if not response_text or not response_text.strip(): + logger.warning("Empty response after retry for session {}, using fallback", safe_key) + response_text = _FALLBACK + + except asyncio.TimeoutError: + return _error_json(504, f"Request timed out after {timeout_s}s") + except Exception: + logger.exception("Error processing request for session {}", safe_key) + return _error_json(500, "Internal server error", err_type="server_error") + finally: + locks.release(session_key) + + return web.json_response(_chat_completion_response(response_text, model_name)) + + +async def handle_models(request: web.Request) -> web.Response: + """GET /v1/models""" + model_name = request.app.get("model_name", "nanobot") + return web.json_response({ + "object": "list", + "data": [ + { + "id": model_name, + "object": "model", + "created": 0, + "owned_by": "nanobot", + } + ], + }) + + +async def handle_health(request: web.Request) -> web.Response: + """GET /health""" + return web.json_response({"status": "ok"}) + + +# --------------------------------------------------------------------------- +# App factory +# --------------------------------------------------------------------------- + +def create_app(agent_loop, model_name: str = "nanobot", request_timeout: float = 120.0) -> web.Application: + """Create the aiohttp application. + + Args: + agent_loop: An initialized AgentLoop instance. + model_name: Model name reported in responses. + request_timeout: Per-request timeout in seconds. + """ + app = web.Application() + app["agent_loop"] = agent_loop + app["model_name"] = model_name + app["request_timeout"] = request_timeout + app["session_locks"] = _SessionLocks() + + app.router.add_post("/v1/chat/completions", handle_chat_completions) + app.router.add_get("/v1/models", handle_models) + app.router.add_get("/health", handle_health) + return app + + +def run_server(agent_loop, host: str = "0.0.0.0", port: int = 8900, + model_name: str = "nanobot", request_timeout: float = 120.0) -> None: + """Create and run the server (blocking).""" + app = create_app(agent_loop, model_name=model_name, request_timeout=request_timeout) + web.run_app(app, host=host, port=port, print=lambda msg: logger.info(msg)) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index fc4c261e..208b4e74 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -237,6 +237,83 @@ def _make_provider(config: Config): ) +# ============================================================================ +# OpenAI-Compatible API Server +# ============================================================================ + + +@app.command() +def serve( + port: int = typer.Option(8900, "--port", "-p", help="API server port"), + host: str = typer.Option("0.0.0.0", "--host", "-H", help="Bind address"), + timeout: float = typer.Option(120.0, "--timeout", "-t", help="Per-request timeout (seconds)"), + verbose: bool = typer.Option(False, "--verbose", "-v", help="Show nanobot runtime logs"), +): + """Start the OpenAI-compatible API server (/v1/chat/completions).""" + try: + from aiohttp import web # noqa: F401 + except ImportError: + console.print("[red]aiohttp is required. Install with: pip install aiohttp[/red]") + raise typer.Exit(1) + + from nanobot.config.loader import load_config + from nanobot.api.server import create_app + from loguru import logger + + if verbose: + logger.enable("nanobot") + else: + logger.disable("nanobot") + + config = load_config() + sync_workspace_templates(config.workspace_path) + provider = _make_provider(config) + + from nanobot.bus.queue import MessageBus + from nanobot.agent.loop import AgentLoop + from nanobot.session.manager import SessionManager + + bus = MessageBus() + session_manager = SessionManager(config.workspace_path) + 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, + session_manager=session_manager, + mcp_servers=config.tools.mcp_servers, + channels_config=config.channels, + ) + + model_name = config.agents.defaults.model + console.print(f"{__logo__} Starting OpenAI-compatible API server") + console.print(f" [cyan]Endpoint[/cyan] : http://{host}:{port}/v1/chat/completions") + console.print(f" [cyan]Model[/cyan] : {model_name}") + console.print(f" [cyan]Timeout[/cyan] : {timeout}s") + console.print(f" [cyan]Header[/cyan] : x-session-key (required)") + console.print() + + api_app = create_app(agent_loop, model_name=model_name, request_timeout=timeout) + + async def on_startup(_app): + await agent_loop._connect_mcp() + + async def on_cleanup(_app): + await agent_loop.close_mcp() + + api_app.on_startup.append(on_startup) + api_app.on_cleanup.append(on_cleanup) + + web.run_app(api_app, host=host, port=port, print=lambda msg: logger.info(msg)) + + # ============================================================================ # Gateway / Server # ============================================================================ diff --git a/pyproject.toml b/pyproject.toml index 20dcb1e0..f71faa14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,9 @@ dependencies = [ ] [project.optional-dependencies] +api = [ + "aiohttp>=3.9.0,<4.0.0", +] matrix = [ "matrix-nio[e2e]>=0.25.2", "mistune>=3.0.0,<4.0.0", @@ -53,6 +56,7 @@ matrix = [ dev = [ "pytest>=9.0.0,<10.0.0", "pytest-asyncio>=1.3.0,<2.0.0", + "aiohttp>=3.9.0,<4.0.0", "ruff>=0.1.0", ] diff --git a/tests/test_consolidate_offset.py b/tests/test_consolidate_offset.py index 67551240..fc72e0a6 100644 --- a/tests/test_consolidate_offset.py +++ b/tests/test_consolidate_offset.py @@ -509,7 +509,7 @@ class TestConsolidationDeduplicationGuard: consolidation_calls = 0 - async def _fake_consolidate(_session, archive_all: bool = False) -> None: + async def _fake_consolidate(_session, archive_all: bool = False, **kw) -> None: nonlocal consolidation_calls consolidation_calls += 1 await asyncio.sleep(0.05) @@ -555,7 +555,7 @@ class TestConsolidationDeduplicationGuard: active = 0 max_active = 0 - async def _fake_consolidate(_session, archive_all: bool = False) -> None: + async def _fake_consolidate(_session, archive_all: bool = False, **kw) -> None: nonlocal consolidation_calls, active, max_active consolidation_calls += 1 active += 1 @@ -605,7 +605,7 @@ class TestConsolidationDeduplicationGuard: started = asyncio.Event() - async def _slow_consolidate(_session, archive_all: bool = False) -> None: + async def _slow_consolidate(_session, archive_all: bool = False, **kw) -> None: started.set() await asyncio.sleep(0.1) @@ -652,7 +652,7 @@ class TestConsolidationDeduplicationGuard: release = asyncio.Event() archived_count = 0 - async def _fake_consolidate(sess, archive_all: bool = False) -> bool: + async def _fake_consolidate(sess, archive_all: bool = False, **kw) -> bool: nonlocal archived_count if archive_all: archived_count = len(sess.messages) @@ -707,7 +707,7 @@ class TestConsolidationDeduplicationGuard: loop.sessions.save(session) before_count = len(session.messages) - async def _failing_consolidate(sess, archive_all: bool = False) -> bool: + async def _failing_consolidate(sess, archive_all: bool = False, **kw) -> bool: if archive_all: return False return True @@ -754,7 +754,7 @@ class TestConsolidationDeduplicationGuard: release = asyncio.Event() archived_count = -1 - async def _fake_consolidate(sess, archive_all: bool = False) -> bool: + async def _fake_consolidate(sess, archive_all: bool = False, **kw) -> bool: nonlocal archived_count if archive_all: archived_count = len(sess.messages) @@ -815,7 +815,7 @@ class TestConsolidationDeduplicationGuard: loop._consolidation_locks.setdefault(session.key, asyncio.Lock()) assert session.key in loop._consolidation_locks - async def _ok_consolidate(sess, archive_all: bool = False) -> bool: + async def _ok_consolidate(sess, archive_all: bool = False, **kw) -> bool: return True loop._consolidate_memory = _ok_consolidate # type: ignore[method-assign] diff --git a/tests/test_openai_api.py b/tests/test_openai_api.py new file mode 100644 index 00000000..b4d83157 --- /dev/null +++ b/tests/test_openai_api.py @@ -0,0 +1,883 @@ +"""Tests for the OpenAI-compatible API server.""" + +from __future__ import annotations + +import asyncio +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from nanobot.api.server import _SessionLocks, _chat_completion_response, _error_json, create_app + +# --------------------------------------------------------------------------- +# aiohttp test client helper +# --------------------------------------------------------------------------- + +try: + from aiohttp.test_utils import AioHTTPTestCase, unittest_run_loop + from aiohttp import web + + HAS_AIOHTTP = True +except ImportError: + HAS_AIOHTTP = False + +pytest_plugins = ("pytest_asyncio",) + +# --------------------------------------------------------------------------- +# Unit tests — no aiohttp required +# --------------------------------------------------------------------------- + + +class TestSessionLocks: + def test_acquire_creates_lock(self): + sl = _SessionLocks() + lock = sl.acquire("k1") + assert isinstance(lock, asyncio.Lock) + + def test_same_key_returns_same_lock(self): + sl = _SessionLocks() + l1 = sl.acquire("k1") + l2 = sl.acquire("k1") + assert l1 is l2 + + def test_different_keys_different_locks(self): + sl = _SessionLocks() + l1 = sl.acquire("k1") + l2 = sl.acquire("k2") + assert l1 is not l2 + + def test_release_cleans_up(self): + sl = _SessionLocks() + sl.acquire("k1") + sl.release("k1") + assert "k1" not in sl._locks + + def test_release_keeps_lock_if_still_referenced(self): + sl = _SessionLocks() + sl.acquire("k1") + sl.acquire("k1") + sl.release("k1") + assert "k1" in sl._locks + sl.release("k1") + assert "k1" not in sl._locks + + +class TestResponseHelpers: + def test_error_json(self): + resp = _error_json(400, "bad request") + assert resp.status == 400 + body = json.loads(resp.body) + assert body["error"]["message"] == "bad request" + assert body["error"]["code"] == 400 + + def test_chat_completion_response(self): + result = _chat_completion_response("hello world", "test-model") + assert result["object"] == "chat.completion" + assert result["model"] == "test-model" + assert result["choices"][0]["message"]["content"] == "hello world" + assert result["choices"][0]["finish_reason"] == "stop" + assert result["id"].startswith("chatcmpl-") + + +# --------------------------------------------------------------------------- +# Integration tests — require aiohttp +# --------------------------------------------------------------------------- + + +def _make_mock_agent(response_text: str = "mock response") -> MagicMock: + agent = MagicMock() + agent.process_direct = AsyncMock(return_value=response_text) + agent._connect_mcp = AsyncMock() + agent.close_mcp = AsyncMock() + return agent + + +@pytest.fixture +def mock_agent(): + return _make_mock_agent() + + +@pytest.fixture +def app(mock_agent): + return create_app(mock_agent, model_name="test-model", request_timeout=10.0) + + +@pytest.fixture +def cli(event_loop, aiohttp_client, app): + return event_loop.run_until_complete(aiohttp_client(app)) + + +# ---- Missing header tests ---- + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_missing_session_key_returns_400(aiohttp_client, app): + client = await aiohttp_client(app) + resp = await client.post( + "/v1/chat/completions", + json={"messages": [{"role": "user", "content": "hello"}]}, + ) + assert resp.status == 400 + body = await resp.json() + assert "x-session-key" in body["error"]["message"] + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_empty_session_key_returns_400(aiohttp_client, app): + client = await aiohttp_client(app) + resp = await client.post( + "/v1/chat/completions", + json={"messages": [{"role": "user", "content": "hello"}]}, + headers={"x-session-key": " "}, + ) + assert resp.status == 400 + + +# ---- Missing messages tests ---- + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_missing_messages_returns_400(aiohttp_client, app): + client = await aiohttp_client(app) + resp = await client.post( + "/v1/chat/completions", + json={"model": "test"}, + headers={"x-session-key": "test-key"}, + ) + assert resp.status == 400 + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_no_user_message_returns_400(aiohttp_client, app): + client = await aiohttp_client(app) + resp = await client.post( + "/v1/chat/completions", + json={"messages": [{"role": "system", "content": "you are a bot"}]}, + headers={"x-session-key": "test-key"}, + ) + assert resp.status == 400 + + +# ---- Stream not supported ---- + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_stream_true_returns_400(aiohttp_client, app): + client = await aiohttp_client(app) + resp = await client.post( + "/v1/chat/completions", + json={ + "messages": [{"role": "user", "content": "hello"}], + "stream": True, + }, + headers={"x-session-key": "test-key"}, + ) + assert resp.status == 400 + body = await resp.json() + assert "stream" in body["error"]["message"].lower() + + +# ---- Successful request ---- + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_successful_request(aiohttp_client, mock_agent): + app = create_app(mock_agent, model_name="test-model") + client = await aiohttp_client(app) + resp = await client.post( + "/v1/chat/completions", + json={"messages": [{"role": "user", "content": "hello"}]}, + headers={"x-session-key": "wx:dm:user1"}, + ) + assert resp.status == 200 + body = await resp.json() + assert body["choices"][0]["message"]["content"] == "mock response" + assert body["model"] == "test-model" + mock_agent.process_direct.assert_called_once_with( + content="hello", + session_key="wx:dm:user1", + channel="api", + chat_id="wx:dm:user1", + isolate_memory=True, + disabled_tools={"read_file", "write_file", "edit_file", "list_dir", "exec"}, + ) + + +# ---- Session isolation ---- + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_session_isolation_different_keys(aiohttp_client): + """Two different session keys must route to separate session_key arguments.""" + call_log: list[str] = [] + + async def fake_process(content, session_key="", channel="", chat_id="", + isolate_memory=False, disabled_tools=None): + call_log.append(session_key) + return f"reply to {session_key}" + + agent = MagicMock() + agent.process_direct = fake_process + agent._connect_mcp = AsyncMock() + agent.close_mcp = AsyncMock() + + app = create_app(agent, model_name="m") + client = await aiohttp_client(app) + + r1 = await client.post( + "/v1/chat/completions", + json={"messages": [{"role": "user", "content": "msg1"}]}, + headers={"x-session-key": "wx:dm:alice"}, + ) + r2 = await client.post( + "/v1/chat/completions", + json={"messages": [{"role": "user", "content": "msg2"}]}, + headers={"x-session-key": "wx:group:g1:user:bob"}, + ) + + assert r1.status == 200 + assert r2.status == 200 + + b1 = await r1.json() + b2 = await r2.json() + assert b1["choices"][0]["message"]["content"] == "reply to wx:dm:alice" + assert b2["choices"][0]["message"]["content"] == "reply to wx:group:g1:user:bob" + assert call_log == ["wx:dm:alice", "wx:group:g1:user:bob"] + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_same_session_key_serialized(aiohttp_client): + """Concurrent requests with the same session key must run serially.""" + order: list[str] = [] + barrier = asyncio.Event() + + async def slow_process(content, session_key="", channel="", chat_id="", + isolate_memory=False, disabled_tools=None): + order.append(f"start:{content}") + if content == "first": + barrier.set() + await asyncio.sleep(0.1) # hold lock + else: + await barrier.wait() # ensure "second" starts after "first" begins + order.append(f"end:{content}") + return content + + agent = MagicMock() + agent.process_direct = slow_process + agent._connect_mcp = AsyncMock() + agent.close_mcp = AsyncMock() + + app = create_app(agent, model_name="m") + client = await aiohttp_client(app) + + async def send(msg): + return await client.post( + "/v1/chat/completions", + json={"messages": [{"role": "user", "content": msg}]}, + headers={"x-session-key": "same-key"}, + ) + + r1, r2 = await asyncio.gather(send("first"), send("second")) + assert r1.status == 200 + assert r2.status == 200 + # "first" must fully complete before "second" starts + assert order.index("end:first") < order.index("start:second") + + +# ---- /v1/models ---- + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_models_endpoint(aiohttp_client, app): + client = await aiohttp_client(app) + resp = await client.get("/v1/models") + assert resp.status == 200 + body = await resp.json() + assert body["object"] == "list" + assert len(body["data"]) >= 1 + assert body["data"][0]["id"] == "test-model" + + +# ---- /health ---- + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_health_endpoint(aiohttp_client, app): + client = await aiohttp_client(app) + resp = await client.get("/health") + assert resp.status == 200 + body = await resp.json() + assert body["status"] == "ok" + + +# ---- Multimodal content array ---- + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_multimodal_content_extracts_text(aiohttp_client, mock_agent): + app = create_app(mock_agent, model_name="m") + client = await aiohttp_client(app) + resp = await client.post( + "/v1/chat/completions", + json={ + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "describe this"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}, + ], + } + ] + }, + headers={"x-session-key": "test"}, + ) + assert resp.status == 200 + mock_agent.process_direct.assert_called_once() + call_kwargs = mock_agent.process_direct.call_args + assert call_kwargs.kwargs["content"] == "describe this" + + +# --------------------------------------------------------------------------- +# Memory isolation regression tests (root cause of cross-session leakage) +# --------------------------------------------------------------------------- + + +class TestMemoryIsolation: + """Verify that per-session-key memory prevents cross-session context leakage. + + Root cause: ContextBuilder.build_system_prompt() reads a SHARED + workspace/memory/MEMORY.md into the system prompt of ALL users. + If user_1 writes "my name is Alice" and the agent persists it to + MEMORY.md, user_2/user_N will see it. + + Fix: API mode passes a per-session MemoryStore so each session reads/ + writes its own MEMORY.md. + """ + + def test_context_builder_uses_override_memory(self, tmp_path): + """build_system_prompt with memory_store= must use the override, not global.""" + from nanobot.agent.context import ContextBuilder + from nanobot.agent.memory import MemoryStore + + workspace = tmp_path / "workspace" + workspace.mkdir() + (workspace / "memory").mkdir() + (workspace / "memory" / "MEMORY.md").write_text("Global: I am shared context") + + ctx = ContextBuilder(workspace) + + # Without override → sees global memory + prompt_global = ctx.build_system_prompt() + assert "I am shared context" in prompt_global + + # With override → sees only the override's memory + override_dir = tmp_path / "isolated" / "memory" + override_dir.mkdir(parents=True) + (override_dir / "MEMORY.md").write_text("User Alice's private note") + + override_store = MemoryStore.__new__(MemoryStore) + override_store.memory_dir = override_dir + override_store.memory_file = override_dir / "MEMORY.md" + override_store.history_file = override_dir / "HISTORY.md" + + prompt_isolated = ctx.build_system_prompt(memory_store=override_store) + assert "User Alice's private note" in prompt_isolated + assert "I am shared context" not in prompt_isolated + + def test_different_session_keys_get_different_memory_dirs(self, tmp_path): + """_isolated_memory_store must return distinct paths for distinct keys.""" + from unittest.mock import MagicMock + from nanobot.agent.loop import AgentLoop + + agent = MagicMock(spec=AgentLoop) + agent.workspace = tmp_path + agent._isolated_memory_store = AgentLoop._isolated_memory_store.__get__(agent) + + store_a = agent._isolated_memory_store("wx:dm:alice") + store_b = agent._isolated_memory_store("wx:dm:bob") + + assert store_a.memory_file != store_b.memory_file + assert store_a.memory_dir != store_b.memory_dir + assert store_a.memory_file.parent.exists() + assert store_b.memory_file.parent.exists() + + def test_isolated_memory_does_not_leak_across_sessions(self, tmp_path): + """End-to-end: writing to one session's memory must not appear in another's.""" + from nanobot.agent.context import ContextBuilder + from nanobot.agent.memory import MemoryStore + + workspace = tmp_path / "workspace" + workspace.mkdir() + (workspace / "memory").mkdir() + (workspace / "memory" / "MEMORY.md").write_text("") + + ctx = ContextBuilder(workspace) + + # Simulate two isolated memory stores (as the API server would create) + def make_store(name): + d = tmp_path / "sessions" / name / "memory" + d.mkdir(parents=True) + s = MemoryStore.__new__(MemoryStore) + s.memory_dir = d + s.memory_file = d / "MEMORY.md" + s.history_file = d / "HISTORY.md" + return s + + store_alice = make_store("wx_dm_alice") + store_bob = make_store("wx_dm_bob") + + # Use unique markers that won't appear in builtin skills/prompts + alice_marker = "XYZZY_ALICE_PRIVATE_MARKER_42" + store_alice.write_long_term(alice_marker) + + # Alice's prompt sees it + prompt_alice = ctx.build_system_prompt(memory_store=store_alice) + assert alice_marker in prompt_alice + + # Bob's prompt must NOT see it + prompt_bob = ctx.build_system_prompt(memory_store=store_bob) + assert alice_marker not in prompt_bob + + # Global prompt must NOT see it either + prompt_global = ctx.build_system_prompt() + assert alice_marker not in prompt_global + + def test_build_messages_passes_memory_store(self, tmp_path): + """build_messages must forward memory_store to build_system_prompt.""" + from nanobot.agent.context import ContextBuilder + from nanobot.agent.memory import MemoryStore + + workspace = tmp_path / "workspace" + workspace.mkdir() + (workspace / "memory").mkdir() + (workspace / "memory" / "MEMORY.md").write_text("GLOBAL_SECRET") + + ctx = ContextBuilder(workspace) + + override_dir = tmp_path / "per_session" / "memory" + override_dir.mkdir(parents=True) + (override_dir / "MEMORY.md").write_text("SESSION_PRIVATE") + + override_store = MemoryStore.__new__(MemoryStore) + override_store.memory_dir = override_dir + override_store.memory_file = override_dir / "MEMORY.md" + override_store.history_file = override_dir / "HISTORY.md" + + messages = ctx.build_messages( + history=[], current_message="hello", + memory_store=override_store, + ) + system_content = messages[0]["content"] + assert "SESSION_PRIVATE" in system_content + assert "GLOBAL_SECRET" not in system_content + + def test_api_handler_passes_isolate_memory_and_disabled_tools(self): + """The API handler must call process_direct with isolate_memory=True and disabled filesystem tools.""" + import ast + from pathlib import Path + + server_path = Path(__file__).parent.parent / "nanobot" / "api" / "server.py" + source = server_path.read_text() + tree = ast.parse(source) + + found_isolate = False + found_disabled = False + for node in ast.walk(tree): + if isinstance(node, ast.keyword): + if node.arg == "isolate_memory" and isinstance(node.value, ast.Constant) and node.value.value is True: + found_isolate = True + if node.arg == "disabled_tools": + found_disabled = True + assert found_isolate, "server.py must call process_direct with isolate_memory=True" + assert found_disabled, "server.py must call process_direct with disabled_tools" + + def test_disabled_tools_constant_blocks_filesystem_and_exec(self): + """_API_DISABLED_TOOLS must include all filesystem tool names and exec.""" + from nanobot.api.server import _API_DISABLED_TOOLS + for name in ("read_file", "write_file", "edit_file", "list_dir", "exec"): + assert name in _API_DISABLED_TOOLS, f"{name} missing from _API_DISABLED_TOOLS" + + def test_system_prompt_uses_isolated_memory_path(self, tmp_path): + """When memory_store is provided, the system prompt must reference + the store's paths, NOT the global workspace/memory/MEMORY.md.""" + from nanobot.agent.context import ContextBuilder + from nanobot.agent.memory import MemoryStore + + workspace = tmp_path / "workspace" + workspace.mkdir() + (workspace / "memory").mkdir() + + ctx = ContextBuilder(workspace) + + # Default prompt references global path + default_prompt = ctx.build_system_prompt() + assert "memory/MEMORY.md" in default_prompt + + # Isolated store + iso_dir = tmp_path / "sessions" / "wx_dm_alice" / "memory" + iso_dir.mkdir(parents=True) + store = MemoryStore.__new__(MemoryStore) + store.memory_dir = iso_dir + store.memory_file = iso_dir / "MEMORY.md" + store.history_file = iso_dir / "HISTORY.md" + + iso_prompt = ctx.build_system_prompt(memory_store=store) + # Must reference the isolated path + assert str(iso_dir / "MEMORY.md") in iso_prompt + assert str(iso_dir / "HISTORY.md") in iso_prompt + # Must NOT reference the global workspace memory path + global_mem = str(workspace.resolve() / "memory" / "MEMORY.md") + assert global_mem not in iso_prompt + + def test_run_agent_loop_filters_disabled_tools(self): + """_run_agent_loop must exclude disabled tools from definitions + and reject execution of disabled tools.""" + from nanobot.agent.tools.registry import ToolRegistry + + registry = ToolRegistry() + + # Create minimal fake tool definitions + class FakeTool: + def __init__(self, n): + self._name = n + + @property + def name(self): + return self._name + + def to_schema(self): + return {"type": "function", "function": {"name": self._name, "parameters": {}}} + + def validate_params(self, params): + return [] + + async def execute(self, **kw): + return "ok" + + for n in ("read_file", "write_file", "web_search", "exec"): + registry.register(FakeTool(n)) + + all_defs = registry.get_definitions() + assert len(all_defs) == 4 + + disabled = {"read_file", "write_file"} + filtered = [d for d in all_defs + if d.get("function", {}).get("name") not in disabled] + assert len(filtered) == 2 + names = {d["function"]["name"] for d in filtered} + assert names == {"web_search", "exec"} + + +# --------------------------------------------------------------------------- +# Consolidation isolation regression tests +# --------------------------------------------------------------------------- + + +class TestConsolidationIsolation: + """Verify that memory consolidation in API (isolate_memory) mode writes + to the per-session directory and never touches global workspace/memory.""" + + @pytest.mark.asyncio + async def test_consolidate_memory_uses_provided_store(self, tmp_path): + """_consolidate_memory(memory_store=X) must call X.consolidate, + not MemoryStore(self.workspace).consolidate.""" + from unittest.mock import AsyncMock, MagicMock, patch + from nanobot.agent.loop import AgentLoop + from nanobot.agent.memory import MemoryStore + from nanobot.session.manager import Session + + agent = MagicMock(spec=AgentLoop) + agent.workspace = tmp_path / "workspace" + agent.workspace.mkdir() + agent.provider = MagicMock() + agent.model = "test" + agent.memory_window = 50 + + # Bind the real method + agent._consolidate_memory = AgentLoop._consolidate_memory.__get__(agent) + + session = Session(key="test") + session.messages = [{"role": "user", "content": "hi", "timestamp": "2025-01-01T00:00"}] * 10 + + # Create an isolated store and mock its consolidate + iso_store = MagicMock(spec=MemoryStore) + iso_store.consolidate = AsyncMock(return_value=True) + + result = await agent._consolidate_memory(session, memory_store=iso_store) + + assert result is True + iso_store.consolidate.assert_called_once() + call_args = iso_store.consolidate.call_args + assert call_args[0][0] is session # first positional arg is session + + @pytest.mark.asyncio + async def test_consolidate_memory_defaults_to_global_when_no_store(self, tmp_path): + """Without memory_store, _consolidate_memory must use MemoryStore(workspace).""" + from unittest.mock import AsyncMock, MagicMock, patch + from nanobot.agent.loop import AgentLoop + from nanobot.session.manager import Session + + agent = MagicMock(spec=AgentLoop) + agent.workspace = tmp_path / "workspace" + agent.workspace.mkdir() + (agent.workspace / "memory").mkdir() + agent.provider = MagicMock() + agent.model = "test" + agent.memory_window = 50 + agent._consolidate_memory = AgentLoop._consolidate_memory.__get__(agent) + + session = Session(key="test") + + with patch("nanobot.agent.loop.MemoryStore") as MockStore: + mock_instance = MagicMock() + mock_instance.consolidate = AsyncMock(return_value=True) + MockStore.return_value = mock_instance + + await agent._consolidate_memory(session) + + MockStore.assert_called_once_with(agent.workspace) + mock_instance.consolidate.assert_called_once() + + def test_consolidate_writes_to_isolated_dir_not_global(self, tmp_path): + """End-to-end: MemoryStore.consolidate with an isolated store must + write HISTORY.md in the isolated dir, not in workspace/memory.""" + from nanobot.agent.memory import MemoryStore + + # Set up global workspace memory + global_mem_dir = tmp_path / "workspace" / "memory" + global_mem_dir.mkdir(parents=True) + (global_mem_dir / "MEMORY.md").write_text("") + (global_mem_dir / "HISTORY.md").write_text("") + + # Set up isolated per-session store + iso_dir = tmp_path / "sessions" / "wx_dm_alice" / "memory" + iso_dir.mkdir(parents=True) + + iso_store = MemoryStore.__new__(MemoryStore) + iso_store.memory_dir = iso_dir + iso_store.memory_file = iso_dir / "MEMORY.md" + iso_store.history_file = iso_dir / "HISTORY.md" + + # Write via the isolated store + iso_store.write_long_term("Alice's private data") + iso_store.append_history("[2025-01-01 00:00] Alice asked about X") + + # Isolated store has the data + assert "Alice's private data" in iso_store.read_long_term() + assert "Alice asked about X" in iso_store.history_file.read_text() + + # Global store must NOT have it + assert (global_mem_dir / "MEMORY.md").read_text() == "" + assert (global_mem_dir / "HISTORY.md").read_text() == "" + + def test_process_message_passes_memory_store_to_consolidation_paths(self): + """Verify that _process_message passes memory_store to both + consolidation triggers (source code check).""" + import ast + from pathlib import Path + + loop_path = Path(__file__).parent.parent / "nanobot" / "agent" / "loop.py" + source = loop_path.read_text() + tree = ast.parse(source) + + # Find all calls to self._consolidate_memory inside _process_message + # and verify they all pass memory_store= + for node in ast.walk(tree): + if not isinstance(node, ast.FunctionDef) or node.name != "_process_message": + continue + consolidate_calls = [] + for child in ast.walk(node): + if (isinstance(child, ast.Call) + and isinstance(child.func, ast.Attribute) + and child.func.attr == "_consolidate_memory"): + kw_names = {kw.arg for kw in child.keywords} + consolidate_calls.append(kw_names) + + assert len(consolidate_calls) == 2, ( + f"Expected 2 _consolidate_memory calls in _process_message, " + f"found {len(consolidate_calls)}" + ) + for i, kw_names in enumerate(consolidate_calls): + assert "memory_store" in kw_names, ( + f"_consolidate_memory call #{i+1} in _process_message " + f"missing memory_store= keyword argument" + ) + + +# --------------------------------------------------------------------------- +# Empty response retry + fallback tests +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_empty_response_retry_then_success(aiohttp_client): + """First call returns empty → retry once → second call returns real text.""" + call_count = 0 + + async def sometimes_empty(content, session_key="", channel="", chat_id="", + isolate_memory=False, disabled_tools=None): + nonlocal call_count + call_count += 1 + if call_count == 1: + return "" + return "recovered response" + + agent = MagicMock() + agent.process_direct = sometimes_empty + agent._connect_mcp = AsyncMock() + agent.close_mcp = AsyncMock() + + app = create_app(agent, model_name="m") + client = await aiohttp_client(app) + + resp = await client.post( + "/v1/chat/completions", + json={"messages": [{"role": "user", "content": "hello"}]}, + headers={"x-session-key": "retry-test"}, + ) + assert resp.status == 200 + body = await resp.json() + assert body["choices"][0]["message"]["content"] == "recovered response" + assert call_count == 2 + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_empty_response_both_empty_returns_fallback(aiohttp_client): + """Both calls return empty → must use the fallback text.""" + call_count = 0 + + async def always_empty(content, session_key="", channel="", chat_id="", + isolate_memory=False, disabled_tools=None): + nonlocal call_count + call_count += 1 + return "" + + agent = MagicMock() + agent.process_direct = always_empty + agent._connect_mcp = AsyncMock() + agent.close_mcp = AsyncMock() + + app = create_app(agent, model_name="m") + client = await aiohttp_client(app) + + resp = await client.post( + "/v1/chat/completions", + json={"messages": [{"role": "user", "content": "hello"}]}, + headers={"x-session-key": "fallback-test"}, + ) + assert resp.status == 200 + body = await resp.json() + assert body["choices"][0]["message"]["content"] == "I've completed processing but have no response to give." + assert call_count == 2 + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_whitespace_only_response_triggers_retry(aiohttp_client): + """Whitespace-only response should be treated as empty and trigger retry.""" + call_count = 0 + + async def whitespace_then_ok(content, session_key="", channel="", chat_id="", + isolate_memory=False, disabled_tools=None): + nonlocal call_count + call_count += 1 + if call_count == 1: + return " \n " + return "real answer" + + agent = MagicMock() + agent.process_direct = whitespace_then_ok + agent._connect_mcp = AsyncMock() + agent.close_mcp = AsyncMock() + + app = create_app(agent, model_name="m") + client = await aiohttp_client(app) + + resp = await client.post( + "/v1/chat/completions", + json={"messages": [{"role": "user", "content": "hello"}]}, + headers={"x-session-key": "ws-test"}, + ) + assert resp.status == 200 + body = await resp.json() + assert body["choices"][0]["message"]["content"] == "real answer" + assert call_count == 2 + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_none_response_triggers_retry(aiohttp_client): + """None response should be treated as empty and trigger retry.""" + call_count = 0 + + async def none_then_ok(content, session_key="", channel="", chat_id="", + isolate_memory=False, disabled_tools=None): + nonlocal call_count + call_count += 1 + if call_count == 1: + return None + return "got it" + + agent = MagicMock() + agent.process_direct = none_then_ok + agent._connect_mcp = AsyncMock() + agent.close_mcp = AsyncMock() + + app = create_app(agent, model_name="m") + client = await aiohttp_client(app) + + resp = await client.post( + "/v1/chat/completions", + json={"messages": [{"role": "user", "content": "hello"}]}, + headers={"x-session-key": "none-test"}, + ) + assert resp.status == 200 + body = await resp.json() + assert body["choices"][0]["message"]["content"] == "got it" + assert call_count == 2 + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_nonempty_response_no_retry(aiohttp_client): + """A normal non-empty response must NOT trigger a retry.""" + call_count = 0 + + async def normal_response(content, session_key="", channel="", chat_id="", + isolate_memory=False, disabled_tools=None): + nonlocal call_count + call_count += 1 + return "immediate answer" + + agent = MagicMock() + agent.process_direct = normal_response + agent._connect_mcp = AsyncMock() + agent.close_mcp = AsyncMock() + + app = create_app(agent, model_name="m") + client = await aiohttp_client(app) + + resp = await client.post( + "/v1/chat/completions", + json={"messages": [{"role": "user", "content": "hello"}]}, + headers={"x-session-key": "normal-test"}, + ) + assert resp.status == 200 + body = await resp.json() + assert body["choices"][0]["message"]["content"] == "immediate answer" + assert call_count == 1 From e868fb32d2cf83d17eadfa885b616a576567fd98 Mon Sep 17 00:00:00 2001 From: Tink Date: Fri, 6 Mar 2026 19:09:38 +0800 Subject: [PATCH 02/91] fix: add from __future__ import annotations to fix Python <3.11 compat These two files from upstream use PEP 604 union syntax (str | None) without the future annotations import. While the project requires Python >=3.11, this makes local testing possible on 3.9/3.10. Co-Authored-By: Claude Opus 4.6 --- nanobot/agent/skills.py | 2 ++ nanobot/utils/helpers.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/nanobot/agent/skills.py b/nanobot/agent/skills.py index 9afee82f..0e138825 100644 --- a/nanobot/agent/skills.py +++ b/nanobot/agent/skills.py @@ -1,5 +1,7 @@ """Skills loader for agent capabilities.""" +from __future__ import annotations + import json import os import re diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index c57c3654..7e6531a8 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -1,5 +1,7 @@ """Utility functions for nanobot.""" +from __future__ import annotations + import re from datetime import datetime from pathlib import Path From 6b3997c463df94242121c556bd539da676433dad Mon Sep 17 00:00:00 2001 From: Tink Date: Fri, 6 Mar 2026 19:13:56 +0800 Subject: [PATCH 03/91] fix: add from __future__ import annotations across codebase Ensure all modules using PEP 604 union syntax (X | Y) include the future annotations import for Python <3.10 compatibility. While the project requires >=3.11, this avoids import-time TypeErrors when running tests on older interpreters. Co-Authored-By: Claude Opus 4.6 --- nanobot/agent/context.py | 2 ++ nanobot/agent/subagent.py | 2 ++ nanobot/agent/tools/base.py | 2 ++ nanobot/agent/tools/cron.py | 2 ++ nanobot/agent/tools/filesystem.py | 2 ++ nanobot/agent/tools/mcp.py | 2 ++ nanobot/agent/tools/message.py | 2 ++ nanobot/agent/tools/registry.py | 2 ++ nanobot/agent/tools/shell.py | 2 ++ nanobot/agent/tools/spawn.py | 2 ++ nanobot/agent/tools/web.py | 2 ++ nanobot/bus/events.py | 2 ++ nanobot/channels/base.py | 2 ++ nanobot/channels/dingtalk.py | 2 ++ nanobot/channels/discord.py | 2 ++ nanobot/channels/email.py | 2 ++ nanobot/channels/feishu.py | 2 ++ nanobot/channels/matrix.py | 2 ++ nanobot/channels/qq.py | 2 ++ nanobot/channels/slack.py | 2 ++ nanobot/cli/commands.py | 2 ++ nanobot/config/loader.py | 2 ++ nanobot/config/schema.py | 2 ++ nanobot/cron/service.py | 2 ++ nanobot/cron/types.py | 2 ++ nanobot/providers/base.py | 2 ++ nanobot/providers/litellm_provider.py | 2 ++ nanobot/providers/transcription.py | 2 ++ nanobot/session/manager.py | 2 ++ 29 files changed, 58 insertions(+) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 6a43d3e9..905562a9 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -1,5 +1,7 @@ """Context builder for assembling agent prompts.""" +from __future__ import annotations + import base64 import mimetypes import platform diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index f2d6ee5f..20dbaede 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -1,5 +1,7 @@ """Subagent manager for background task execution.""" +from __future__ import annotations + import asyncio import json import uuid diff --git a/nanobot/agent/tools/base.py b/nanobot/agent/tools/base.py index 051fc9ac..ea5b6631 100644 --- a/nanobot/agent/tools/base.py +++ b/nanobot/agent/tools/base.py @@ -1,5 +1,7 @@ """Base class for agent tools.""" +from __future__ import annotations + from abc import ABC, abstractmethod from typing import Any diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index f8e737b3..350e261f 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -1,5 +1,7 @@ """Cron tool for scheduling reminders and tasks.""" +from __future__ import annotations + from contextvars import ContextVar from typing import Any diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index 7b0b8672..c13464e6 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -1,5 +1,7 @@ """File system tools: read, write, edit.""" +from __future__ import annotations + import difflib from pathlib import Path from typing import Any diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index 2cbffd09..dd6ce8c5 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -1,5 +1,7 @@ """MCP client: connects to MCP servers and wraps their tools as native nanobot tools.""" +from __future__ import annotations + import asyncio from contextlib import AsyncExitStack from typing import Any diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py index 35e519a0..9d7cfbdc 100644 --- a/nanobot/agent/tools/message.py +++ b/nanobot/agent/tools/message.py @@ -1,5 +1,7 @@ """Message tool for sending messages to users.""" +from __future__ import annotations + from typing import Any, Awaitable, Callable from nanobot.agent.tools.base import Tool diff --git a/nanobot/agent/tools/registry.py b/nanobot/agent/tools/registry.py index 5d36e52c..6edb88e1 100644 --- a/nanobot/agent/tools/registry.py +++ b/nanobot/agent/tools/registry.py @@ -1,5 +1,7 @@ """Tool registry for dynamic tool management.""" +from __future__ import annotations + from typing import Any from nanobot.agent.tools.base import Tool diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index ce199209..74d1923f 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -1,5 +1,7 @@ """Shell execution tool.""" +from __future__ import annotations + import asyncio import os import re diff --git a/nanobot/agent/tools/spawn.py b/nanobot/agent/tools/spawn.py index fc62bf8d..935dd319 100644 --- a/nanobot/agent/tools/spawn.py +++ b/nanobot/agent/tools/spawn.py @@ -1,5 +1,7 @@ """Spawn tool for creating background subagents.""" +from __future__ import annotations + from typing import TYPE_CHECKING, Any from nanobot.agent.tools.base import Tool diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index 0d8f4d16..61920d98 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -1,5 +1,7 @@ """Web tools: web_search and web_fetch.""" +from __future__ import annotations + import html import json import os diff --git a/nanobot/bus/events.py b/nanobot/bus/events.py index 018c25b3..0bc8f397 100644 --- a/nanobot/bus/events.py +++ b/nanobot/bus/events.py @@ -1,5 +1,7 @@ """Event types for the message bus.""" +from __future__ import annotations + from dataclasses import dataclass, field from datetime import datetime from typing import Any diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index b38fcaf2..296426c6 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -1,5 +1,7 @@ """Base channel interface for chat platforms.""" +from __future__ import annotations + from abc import ABC, abstractmethod from typing import Any diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py index 8d02fa6c..76f25d11 100644 --- a/nanobot/channels/dingtalk.py +++ b/nanobot/channels/dingtalk.py @@ -1,5 +1,7 @@ """DingTalk/DingDing channel implementation using Stream Mode.""" +from __future__ import annotations + import asyncio import json import mimetypes diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index c868bbf3..fd492674 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -1,5 +1,7 @@ """Discord channel implementation using Discord Gateway websocket.""" +from __future__ import annotations + import asyncio import json from pathlib import Path diff --git a/nanobot/channels/email.py b/nanobot/channels/email.py index 16771fb6..d0e1b61d 100644 --- a/nanobot/channels/email.py +++ b/nanobot/channels/email.py @@ -1,5 +1,7 @@ """Email channel implementation using IMAP polling + SMTP replies.""" +from __future__ import annotations + import asyncio import html import imaplib diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 8f69c095..e56b7da2 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -1,5 +1,7 @@ """Feishu/Lark channel implementation using lark-oapi SDK with WebSocket long connection.""" +from __future__ import annotations + import asyncio import json import os diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 4967ac13..488b607e 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -1,5 +1,7 @@ """Matrix (Element) channel — inbound sync + outbound message/media delivery.""" +from __future__ import annotations + import asyncio import logging import mimetypes diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 6c580490..1a4c8af0 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -1,5 +1,7 @@ """QQ channel implementation using botpy SDK.""" +from __future__ import annotations + import asyncio from collections import deque from typing import TYPE_CHECKING diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index afd1d2dc..7301ced6 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -1,5 +1,7 @@ """Slack channel implementation using Socket Mode.""" +from __future__ import annotations + import asyncio import re from typing import Any diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index b28dcedc..8035b263 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -1,5 +1,7 @@ """CLI commands for nanobot.""" +from __future__ import annotations + import asyncio import os import select diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py index c789efda..d16c0d46 100644 --- a/nanobot/config/loader.py +++ b/nanobot/config/loader.py @@ -1,5 +1,7 @@ """Configuration loading utilities.""" +from __future__ import annotations + import json from pathlib import Path diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 2073eeb0..5eefa831 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -1,5 +1,7 @@ """Configuration schema using Pydantic.""" +from __future__ import annotations + from pathlib import Path from typing import Literal diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index 1ed71f0f..c9cd8681 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -1,5 +1,7 @@ """Cron service for scheduling agent tasks.""" +from __future__ import annotations + import asyncio import json import time diff --git a/nanobot/cron/types.py b/nanobot/cron/types.py index 2b420605..209fddf5 100644 --- a/nanobot/cron/types.py +++ b/nanobot/cron/types.py @@ -1,5 +1,7 @@ """Cron types.""" +from __future__ import annotations + from dataclasses import dataclass, field from typing import Literal diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index 55bd8057..7a90db4d 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -1,5 +1,7 @@ """Base LLM provider interface.""" +from __future__ import annotations + from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import Any diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 620424e6..5a76cb0e 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -1,5 +1,7 @@ """LiteLLM provider implementation for multi-provider support.""" +from __future__ import annotations + import os import secrets import string diff --git a/nanobot/providers/transcription.py b/nanobot/providers/transcription.py index 1c8cb6a3..d7fa9b3d 100644 --- a/nanobot/providers/transcription.py +++ b/nanobot/providers/transcription.py @@ -1,5 +1,7 @@ """Voice transcription provider using Groq.""" +from __future__ import annotations + import os from pathlib import Path diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index dce4b2ec..2cde436e 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -1,5 +1,7 @@ """Session management for conversation history.""" +from __future__ import annotations + import json import shutil from dataclasses import dataclass, field From 9d69ba9f56a7e99e64f689ce2aaa37a82d17ffdb Mon Sep 17 00:00:00 2001 From: Tink Date: Fri, 13 Mar 2026 19:26:50 +0800 Subject: [PATCH 04/91] fix: isolate /new consolidation in API mode --- nanobot/agent/loop.py | 14 ++++---- nanobot/agent/memory.py | 25 +++++++++---- tests/test_consolidate_offset.py | 36 +++++++++++++++++-- tests/test_loop_consolidation_tokens.py | 2 +- tests/test_openai_api.py | 47 +++++++++++++++++++++++++ 5 files changed, 108 insertions(+), 16 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index ea14bc01..47406890 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -14,7 +14,7 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable from loguru import logger from nanobot.agent.context import ContextBuilder -from nanobot.agent.memory import MemoryConsolidator +from nanobot.agent.memory import MemoryConsolidator, MemoryStore from nanobot.agent.subagent import SubagentManager from nanobot.agent.tools.cron import CronTool from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool @@ -362,7 +362,7 @@ class AgentLoop: logger.info("Processing system message from {}", msg.sender_id) key = f"{channel}:{chat_id}" session = self.sessions.get_or_create(key) - await self.memory_consolidator.maybe_consolidate_by_tokens(session) + await self.memory_consolidator.maybe_consolidate_by_tokens(session, store=memory_store) self._set_tool_context(channel, chat_id, msg.metadata.get("message_id")) history = session.get_history(max_messages=0) messages = self.context.build_messages( @@ -375,7 +375,7 @@ class AgentLoop: ) self._save_turn(session, all_msgs, 1 + len(history)) self.sessions.save(session) - await self.memory_consolidator.maybe_consolidate_by_tokens(session) + await self.memory_consolidator.maybe_consolidate_by_tokens(session, store=memory_store) return OutboundMessage(channel=channel, chat_id=chat_id, content=final_content or "Background task completed.") @@ -389,7 +389,9 @@ class AgentLoop: cmd = msg.content.strip().lower() if cmd == "/new": try: - if not await self.memory_consolidator.archive_unconsolidated(session): + if not await self.memory_consolidator.archive_unconsolidated( + session, store=memory_store, + ): return OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, @@ -419,7 +421,7 @@ class AgentLoop: return OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content="\n".join(lines), ) - await self.memory_consolidator.maybe_consolidate_by_tokens(session) + await self.memory_consolidator.maybe_consolidate_by_tokens(session, store=memory_store) self._set_tool_context(msg.channel, msg.chat_id, msg.metadata.get("message_id")) if message_tool := self.tools.get("message"): @@ -453,7 +455,7 @@ class AgentLoop: self._save_turn(session, all_msgs, 1 + len(history)) self.sessions.save(session) - await self.memory_consolidator.maybe_consolidate_by_tokens(session) + await self.memory_consolidator.maybe_consolidate_by_tokens(session, store=memory_store) if (mt := self.tools.get("message")) and isinstance(mt, MessageTool) and mt._sent_in_turn: return None diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index f220f234..407cc20f 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -247,9 +247,14 @@ class MemoryConsolidator: """Return the shared consolidation lock for one session.""" return self._locks.setdefault(session_key, asyncio.Lock()) - async def consolidate_messages(self, messages: list[dict[str, object]]) -> bool: + async def consolidate_messages( + self, + messages: list[dict[str, object]], + store: MemoryStore | None = None, + ) -> bool: """Archive a selected message chunk into persistent memory.""" - return await self.store.consolidate(messages, self.provider, self.model) + target = store or self.store + return await target.consolidate(messages, self.provider, self.model) def pick_consolidation_boundary( self, @@ -290,16 +295,24 @@ class MemoryConsolidator: self._get_tool_definitions(), ) - async def archive_unconsolidated(self, session: Session) -> bool: + async def archive_unconsolidated( + self, + session: Session, + store: MemoryStore | None = None, + ) -> bool: """Archive the full unconsolidated tail for /new-style session rollover.""" lock = self.get_lock(session.key) async with lock: snapshot = session.messages[session.last_consolidated:] if not snapshot: return True - return await self.consolidate_messages(snapshot) + return await self.consolidate_messages(snapshot, store=store) - async def maybe_consolidate_by_tokens(self, session: Session) -> None: + async def maybe_consolidate_by_tokens( + self, + session: Session, + store: MemoryStore | None = None, + ) -> None: """Loop: archive old messages until prompt fits within half the context window.""" if not session.messages or self.context_window_tokens <= 0: return @@ -347,7 +360,7 @@ class MemoryConsolidator: source, len(chunk), ) - if not await self.consolidate_messages(chunk): + if not await self.consolidate_messages(chunk, store=store): return session.last_consolidated = end_idx self.sessions.save(session) diff --git a/tests/test_consolidate_offset.py b/tests/test_consolidate_offset.py index 7d12338a..bea193fc 100644 --- a/tests/test_consolidate_offset.py +++ b/tests/test_consolidate_offset.py @@ -516,7 +516,7 @@ class TestNewCommandArchival: loop.sessions.save(session) before_count = len(session.messages) - async def _failing_consolidate(_messages) -> bool: + async def _failing_consolidate(_messages, store=None) -> bool: return False loop.memory_consolidator.consolidate_messages = _failing_consolidate # type: ignore[method-assign] @@ -542,7 +542,7 @@ class TestNewCommandArchival: archived_count = -1 - async def _fake_consolidate(messages) -> bool: + async def _fake_consolidate(messages, store=None) -> bool: nonlocal archived_count archived_count = len(messages) return True @@ -567,7 +567,7 @@ class TestNewCommandArchival: session.add_message("assistant", f"resp{i}") loop.sessions.save(session) - async def _ok_consolidate(_messages) -> bool: + async def _ok_consolidate(_messages, store=None) -> bool: return True loop.memory_consolidator.consolidate_messages = _ok_consolidate # type: ignore[method-assign] @@ -578,3 +578,33 @@ class TestNewCommandArchival: assert response is not None assert "new session started" in response.content.lower() assert loop.sessions.get_or_create("cli:test").messages == [] + + @pytest.mark.asyncio + async def test_new_archives_to_custom_store_when_provided(self, tmp_path: Path) -> None: + """When memory_store is passed, /new must archive through that store.""" + from nanobot.bus.events import InboundMessage + from nanobot.agent.memory import MemoryStore + + loop = self._make_loop(tmp_path) + session = loop.sessions.get_or_create("cli:test") + for i in range(5): + session.add_message("user", f"msg{i}") + session.add_message("assistant", f"resp{i}") + loop.sessions.save(session) + + used_store = None + + async def _tracking_consolidate(messages, store=None) -> bool: + nonlocal used_store + used_store = store + return True + + loop.memory_consolidator.consolidate_messages = _tracking_consolidate # type: ignore[method-assign] + + iso_store = MagicMock(spec=MemoryStore) + new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new") + response = await loop._process_message(new_msg, memory_store=iso_store) + + assert response is not None + assert "new session started" in response.content.lower() + assert used_store is iso_store, "archive_unconsolidated must use the provided store" diff --git a/tests/test_loop_consolidation_tokens.py b/tests/test_loop_consolidation_tokens.py index b0f3dda5..7daa3880 100644 --- a/tests/test_loop_consolidation_tokens.py +++ b/tests/test_loop_consolidation_tokens.py @@ -158,7 +158,7 @@ async def test_preflight_consolidation_before_llm_call(tmp_path, monkeypatch) -> loop = _make_loop(tmp_path, estimated_tokens=0, context_window_tokens=200) - async def track_consolidate(messages): + async def track_consolidate(messages, store=None): order.append("consolidate") return True loop.memory_consolidator.consolidate_messages = track_consolidate # type: ignore[method-assign] diff --git a/tests/test_openai_api.py b/tests/test_openai_api.py index 216596de..d2d30b8b 100644 --- a/tests/test_openai_api.py +++ b/tests/test_openai_api.py @@ -622,6 +622,53 @@ class TestConsolidationIsolation: assert (global_mem_dir / "MEMORY.md").read_text() == "" assert (global_mem_dir / "HISTORY.md").read_text() == "" + @pytest.mark.asyncio + async def test_new_command_uses_isolated_store(self, tmp_path): + """process_direct(isolate_memory=True) + /new must archive to the isolated store.""" + from unittest.mock import AsyncMock, MagicMock + from nanobot.agent.loop import AgentLoop + from nanobot.agent.memory import MemoryStore + from nanobot.bus.queue import MessageBus + from nanobot.providers.base import LLMResponse + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + provider.estimate_prompt_tokens.return_value = (10_000, "test") + agent = AgentLoop( + bus=bus, provider=provider, workspace=tmp_path, + model="test-model", context_window_tokens=1, + ) + agent._mcp_connected = True # skip MCP connect + agent.tools.get_definitions = MagicMock(return_value=[]) + + # Pre-populate session so /new has something to archive + session = agent.sessions.get_or_create("api:alice") + for i in range(3): + session.add_message("user", f"msg{i}") + session.add_message("assistant", f"resp{i}") + agent.sessions.save(session) + + used_store = None + + async def _tracking_consolidate(messages, store=None) -> bool: + nonlocal used_store + used_store = store + return True + + agent.memory_consolidator.consolidate_messages = _tracking_consolidate # type: ignore[method-assign] + + result = await agent.process_direct( + "/new", session_key="api:alice", isolate_memory=True, + ) + + assert "new session started" in result.lower() + assert used_store is not None, "consolidation must receive a store" + assert isinstance(used_store, MemoryStore) + assert "sessions" in str(used_store.memory_dir), ( + "store must point to per-session dir, not global workspace" + ) + # --------------------------------------------------------------------------- From e04e1c24ff6e775d306a757542b43f3640974c93 Mon Sep 17 00:00:00 2001 From: xcosmosbox <2162381070@qq.com> Date: Sun, 29 Mar 2026 13:01:44 +0800 Subject: [PATCH 05/91] feat(weixin): 1.align protocol headers with package.json metadata 2.support upload_full_url with fallback to upload_param --- nanobot/channels/weixin.py | 66 +++++++++++++++++++++------ tests/channels/test_weixin_channel.py | 64 +++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 14 deletions(-) diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py index f09ef95f..3b62a726 100644 --- a/nanobot/channels/weixin.py +++ b/nanobot/channels/weixin.py @@ -53,7 +53,41 @@ MESSAGE_TYPE_BOT = 2 MESSAGE_STATE_FINISH = 2 WEIXIN_MAX_MESSAGE_LEN = 4000 -WEIXIN_CHANNEL_VERSION = "1.0.3" + + +def _read_reference_package_meta() -> dict[str, str]: + """Best-effort read of reference `package/package.json` metadata.""" + try: + pkg_path = Path(__file__).resolve().parents[2] / "package" / "package.json" + data = json.loads(pkg_path.read_text(encoding="utf-8")) + return { + "version": str(data.get("version", "") or ""), + "ilink_appid": str(data.get("ilink_appid", "") or ""), + } + except Exception: + return {"version": "", "ilink_appid": ""} + + +def _build_client_version(version: str) -> int: + """Encode semantic version as 0x00MMNNPP (major/minor/patch in one uint32).""" + parts = version.split(".") + + def _as_int(idx: int) -> int: + try: + return int(parts[idx]) + except Exception: + return 0 + + major = _as_int(0) + minor = _as_int(1) + patch = _as_int(2) + return ((major & 0xFF) << 16) | ((minor & 0xFF) << 8) | (patch & 0xFF) + + +_PKG_META = _read_reference_package_meta() +WEIXIN_CHANNEL_VERSION = _PKG_META["version"] or "unknown" +ILINK_APP_ID = _PKG_META["ilink_appid"] +ILINK_APP_CLIENT_VERSION = _build_client_version(_PKG_META["version"] or "0.0.0") BASE_INFO: dict[str, str] = {"channel_version": WEIXIN_CHANNEL_VERSION} # Session-expired error code @@ -199,6 +233,8 @@ class WeixinChannel(BaseChannel): "X-WECHAT-UIN": self._random_wechat_uin(), "Content-Type": "application/json", "AuthorizationType": "ilink_bot_token", + "iLink-App-Id": ILINK_APP_ID, + "iLink-App-ClientVersion": str(ILINK_APP_CLIENT_VERSION), } if auth and self._token: headers["Authorization"] = f"Bearer {self._token}" @@ -267,13 +303,10 @@ class WeixinChannel(BaseChannel): logger.info("Waiting for QR code scan...") while self._running: try: - # Reference plugin sends iLink-App-ClientVersion header for - # QR status polling (login-qr.ts:81). status_data = await self._api_get( "ilink/bot/get_qrcode_status", params={"qrcode": qrcode_id}, auth=False, - extra_headers={"iLink-App-ClientVersion": "1"}, ) except httpx.TimeoutException: continue @@ -838,7 +871,7 @@ class WeixinChannel(BaseChannel): # Matches aesEcbPaddedSize: Math.ceil((size + 1) / 16) * 16 padded_size = ((raw_size + 1 + 15) // 16) * 16 - # Step 1: Get upload URL (upload_param) from server + # Step 1: Get upload URL from server (prefer upload_full_url, fallback to upload_param) file_key = os.urandom(16).hex() upload_body: dict[str, Any] = { "filekey": file_key, @@ -855,19 +888,26 @@ class WeixinChannel(BaseChannel): upload_resp = await self._api_post("ilink/bot/getuploadurl", upload_body) logger.debug("WeChat getuploadurl response: {}", upload_resp) - upload_param = upload_resp.get("upload_param", "") - if not upload_param: - raise RuntimeError(f"getuploadurl returned no upload_param: {upload_resp}") + upload_full_url = str(upload_resp.get("upload_full_url", "") or "").strip() + upload_param = str(upload_resp.get("upload_param", "") or "") + if not upload_full_url and not upload_param: + raise RuntimeError( + "getuploadurl returned no upload URL " + f"(need upload_full_url or upload_param): {upload_resp}" + ) # Step 2: AES-128-ECB encrypt and POST to CDN aes_key_b64 = base64.b64encode(aes_key_raw).decode() encrypted_data = _encrypt_aes_ecb(raw_data, aes_key_b64) - cdn_upload_url = ( - f"{self.config.cdn_base_url}/upload" - f"?encrypted_query_param={quote(upload_param)}" - f"&filekey={quote(file_key)}" - ) + if upload_full_url: + cdn_upload_url = upload_full_url + else: + cdn_upload_url = ( + f"{self.config.cdn_base_url}/upload" + f"?encrypted_query_param={quote(upload_param)}" + f"&filekey={quote(file_key)}" + ) logger.debug("WeChat CDN POST url={} ciphertextSize={}", cdn_upload_url[:80], len(encrypted_data)) cdn_resp = await self._client.post( diff --git a/tests/channels/test_weixin_channel.py b/tests/channels/test_weixin_channel.py index 54d9bd93..498e49e9 100644 --- a/tests/channels/test_weixin_channel.py +++ b/tests/channels/test_weixin_channel.py @@ -1,6 +1,7 @@ import asyncio import json import tempfile +from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock @@ -42,10 +43,13 @@ def test_make_headers_includes_route_tag_when_configured() -> None: assert headers["Authorization"] == "Bearer token" assert headers["SKRouteTag"] == "123" + assert headers["iLink-App-Id"] == "bot" + assert headers["iLink-App-ClientVersion"] == str((2 << 16) | (1 << 8) | 1) def test_channel_version_matches_reference_plugin_version() -> None: - assert WEIXIN_CHANNEL_VERSION == "1.0.3" + pkg = json.loads(Path("package/package.json").read_text()) + assert WEIXIN_CHANNEL_VERSION == pkg["version"] def test_save_and_load_state_persists_context_tokens(tmp_path) -> None: @@ -278,3 +282,61 @@ async def test_process_message_skips_bot_messages() -> None: ) assert bus.inbound_size == 0 + + +class _DummyHttpResponse: + def __init__(self, *, headers: dict[str, str] | None = None, status_code: int = 200) -> None: + self.headers = headers or {} + self.status_code = status_code + + def raise_for_status(self) -> None: + return None + + +@pytest.mark.asyncio +async def test_send_media_uses_upload_full_url_when_present(tmp_path) -> None: + channel, _bus = _make_channel() + + media_file = tmp_path / "photo.jpg" + media_file.write_bytes(b"hello-weixin") + + cdn_post = AsyncMock(return_value=_DummyHttpResponse(headers={"x-encrypted-param": "dl-param"})) + channel._client = SimpleNamespace(post=cdn_post) + channel._api_post = AsyncMock( + side_effect=[ + { + "upload_full_url": "https://upload-full.example.test/path?foo=bar", + "upload_param": "should-not-be-used", + }, + {"ret": 0}, + ] + ) + + await channel._send_media_file("wx-user", str(media_file), "ctx-1") + + # first POST call is CDN upload + cdn_url = cdn_post.await_args_list[0].args[0] + assert cdn_url == "https://upload-full.example.test/path?foo=bar" + + +@pytest.mark.asyncio +async def test_send_media_falls_back_to_upload_param_url(tmp_path) -> None: + channel, _bus = _make_channel() + + media_file = tmp_path / "photo.jpg" + media_file.write_bytes(b"hello-weixin") + + cdn_post = AsyncMock(return_value=_DummyHttpResponse(headers={"x-encrypted-param": "dl-param"})) + channel._client = SimpleNamespace(post=cdn_post) + channel._api_post = AsyncMock( + side_effect=[ + {"upload_param": "enc-need-fallback"}, + {"ret": 0}, + ] + ) + + await channel._send_media_file("wx-user", str(media_file), "ctx-1") + + cdn_url = cdn_post.await_args_list[0].args[0] + assert cdn_url.startswith(f"{channel.config.cdn_base_url}/upload?encrypted_query_param=enc-need-fallback") + assert "&filekey=" in cdn_url From b1d547568114750b856bb64f5ff0678707d09f5a Mon Sep 17 00:00:00 2001 From: xcosmosbox <2162381070@qq.com> Date: Sun, 29 Mar 2026 13:14:22 +0800 Subject: [PATCH 06/91] fix(weixin): correct PKCS7 unpadding for AES-ECB; support full_url for media download --- nanobot/channels/weixin.py | 56 +++++++++++++++++------- tests/channels/test_weixin_channel.py | 63 +++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 16 deletions(-) diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py index 3b62a726..c829512b 100644 --- a/nanobot/channels/weixin.py +++ b/nanobot/channels/weixin.py @@ -685,9 +685,10 @@ class WeixinChannel(BaseChannel): """Download + AES-decrypt a media item. Returns local path or None.""" try: media = typed_item.get("media") or {} - encrypt_query_param = media.get("encrypt_query_param", "") + encrypt_query_param = str(media.get("encrypt_query_param", "") or "") + full_url = str(media.get("full_url", "") or "").strip() - if not encrypt_query_param: + if not encrypt_query_param and not full_url: return None # Resolve AES key (media-download.ts:43-45, pic-decrypt.ts:40-52) @@ -704,11 +705,14 @@ class WeixinChannel(BaseChannel): elif media_aes_key_b64: aes_key_b64 = media_aes_key_b64 - # Build CDN download URL with proper URL-encoding (cdn-url.ts:7) - cdn_url = ( - f"{self.config.cdn_base_url}/download" - f"?encrypted_query_param={quote(encrypt_query_param)}" - ) + # Prefer server-provided full_url, fallback to encrypted_query_param URL construction. + if full_url: + cdn_url = full_url + else: + cdn_url = ( + f"{self.config.cdn_base_url}/download" + f"?encrypted_query_param={quote(encrypt_query_param)}" + ) assert self._client is not None resp = await self._client.get(cdn_url) @@ -727,7 +731,8 @@ class WeixinChannel(BaseChannel): ext = _ext_for_type(media_type) if not filename: ts = int(time.time()) - h = abs(hash(encrypt_query_param)) % 100000 + hash_seed = encrypt_query_param or full_url + h = abs(hash(hash_seed)) % 100000 filename = f"{media_type}_{ts}_{h}{ext}" safe_name = os.path.basename(filename) file_path = media_dir / safe_name @@ -1045,23 +1050,42 @@ def _decrypt_aes_ecb(data: bytes, aes_key_b64: str) -> bytes: logger.warning("Failed to parse AES key, returning raw data: {}", e) return data + decrypted: bytes | None = None + try: from Crypto.Cipher import AES cipher = AES.new(key, AES.MODE_ECB) - return cipher.decrypt(data) # pycryptodome auto-strips PKCS7 with unpad + decrypted = cipher.decrypt(data) except ImportError: pass - try: - from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + if decrypted is None: + try: + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes - cipher_obj = Cipher(algorithms.AES(key), modes.ECB()) - decryptor = cipher_obj.decryptor() - return decryptor.update(data) + decryptor.finalize() - except ImportError: - logger.warning("Cannot decrypt media: install 'pycryptodome' or 'cryptography'") + cipher_obj = Cipher(algorithms.AES(key), modes.ECB()) + decryptor = cipher_obj.decryptor() + decrypted = decryptor.update(data) + decryptor.finalize() + except ImportError: + logger.warning("Cannot decrypt media: install 'pycryptodome' or 'cryptography'") + return data + + return _pkcs7_unpad_safe(decrypted) + + +def _pkcs7_unpad_safe(data: bytes, block_size: int = 16) -> bytes: + """Safely remove PKCS7 padding when valid; otherwise return original bytes.""" + if not data: return data + if len(data) % block_size != 0: + return data + pad_len = data[-1] + if pad_len < 1 or pad_len > block_size: + return data + if data[-pad_len:] != bytes([pad_len]) * pad_len: + return data + return data[:-pad_len] def _ext_for_type(media_type: str) -> str: diff --git a/tests/channels/test_weixin_channel.py b/tests/channels/test_weixin_channel.py index 498e49e9..a52aaa80 100644 --- a/tests/channels/test_weixin_channel.py +++ b/tests/channels/test_weixin_channel.py @@ -7,12 +7,15 @@ from unittest.mock import AsyncMock import pytest +import nanobot.channels.weixin as weixin_mod from nanobot.bus.queue import MessageBus from nanobot.channels.weixin import ( ITEM_IMAGE, ITEM_TEXT, MESSAGE_TYPE_BOT, WEIXIN_CHANNEL_VERSION, + _decrypt_aes_ecb, + _encrypt_aes_ecb, WeixinChannel, WeixinConfig, ) @@ -340,3 +343,63 @@ async def test_send_media_falls_back_to_upload_param_url(tmp_path) -> None: cdn_url = cdn_post.await_args_list[0].args[0] assert cdn_url.startswith(f"{channel.config.cdn_base_url}/upload?encrypted_query_param=enc-need-fallback") assert "&filekey=" in cdn_url + + +def test_decrypt_aes_ecb_strips_valid_pkcs7_padding() -> None: + key_b64 = "MDEyMzQ1Njc4OWFiY2RlZg==" # base64("0123456789abcdef") + plaintext = b"hello-weixin-padding" + + ciphertext = _encrypt_aes_ecb(plaintext, key_b64) + decrypted = _decrypt_aes_ecb(ciphertext, key_b64) + + assert decrypted == plaintext + + +class _DummyDownloadResponse: + def __init__(self, content: bytes, status_code: int = 200) -> None: + self.content = content + self.status_code = status_code + + def raise_for_status(self) -> None: + return None + + +@pytest.mark.asyncio +async def test_download_media_item_uses_full_url_when_present(tmp_path) -> None: + channel, _bus = _make_channel() + weixin_mod.get_media_dir = lambda _name: tmp_path + + full_url = "https://cdn.example.test/download/full" + channel._client = SimpleNamespace( + get=AsyncMock(return_value=_DummyDownloadResponse(content=b"raw-image-bytes")) + ) + + item = { + "media": { + "full_url": full_url, + "encrypt_query_param": "enc-fallback-should-not-be-used", + }, + } + saved_path = await channel._download_media_item(item, "image") + + assert saved_path is not None + assert Path(saved_path).read_bytes() == b"raw-image-bytes" + channel._client.get.assert_awaited_once_with(full_url) + + +@pytest.mark.asyncio +async def test_download_media_item_falls_back_to_encrypt_query_param(tmp_path) -> None: + channel, _bus = _make_channel() + weixin_mod.get_media_dir = lambda _name: tmp_path + + channel._client = SimpleNamespace( + get=AsyncMock(return_value=_DummyDownloadResponse(content=b"fallback-bytes")) + ) + + item = {"media": {"encrypt_query_param": "enc-fallback"}} + saved_path = await channel._download_media_item(item, "image") + + assert saved_path is not None + assert Path(saved_path).read_bytes() == b"fallback-bytes" + called_url = channel._client.get.await_args_list[0].args[0] + assert called_url.startswith(f"{channel.config.cdn_base_url}/download?encrypted_query_param=enc-fallback") From 0207b541df85caab2ac2aabc9fbe30b9ba68a672 Mon Sep 17 00:00:00 2001 From: xcosmosbox <2162381070@qq.com> Date: Sun, 29 Mar 2026 13:37:22 +0800 Subject: [PATCH 07/91] feat(weixin): implement QR redirect handling --- nanobot/channels/weixin.py | 42 +++++++++++++- tests/channels/test_weixin_channel.py | 80 +++++++++++++++++++++++++-- 2 files changed, 116 insertions(+), 6 deletions(-) diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py index c829512b..51cef15e 100644 --- a/nanobot/channels/weixin.py +++ b/nanobot/channels/weixin.py @@ -259,6 +259,25 @@ class WeixinChannel(BaseChannel): resp.raise_for_status() return resp.json() + async def _api_get_with_base( + self, + *, + base_url: str, + endpoint: str, + params: dict | None = None, + auth: bool = True, + extra_headers: dict[str, str] | None = None, + ) -> dict: + """GET helper that allows overriding base_url for QR redirect polling.""" + assert self._client is not None + url = f"{base_url.rstrip('/')}/{endpoint}" + hdrs = self._make_headers(auth=auth) + if extra_headers: + hdrs.update(extra_headers) + resp = await self._client.get(url, params=params, headers=hdrs) + resp.raise_for_status() + return resp.json() + async def _api_post( self, endpoint: str, @@ -299,12 +318,14 @@ class WeixinChannel(BaseChannel): refresh_count = 0 qrcode_id, scan_url = await self._fetch_qr_code() self._print_qr_code(scan_url) + current_poll_base_url = self.config.base_url logger.info("Waiting for QR code scan...") while self._running: try: - status_data = await self._api_get( - "ilink/bot/get_qrcode_status", + status_data = await self._api_get_with_base( + base_url=current_poll_base_url, + endpoint="ilink/bot/get_qrcode_status", params={"qrcode": qrcode_id}, auth=False, ) @@ -333,6 +354,23 @@ class WeixinChannel(BaseChannel): return False elif status == "scaned": logger.info("QR code scanned, waiting for confirmation...") + elif status == "scaned_but_redirect": + redirect_host = str(status_data.get("redirect_host", "") or "").strip() + if redirect_host: + if redirect_host.startswith("http://") or redirect_host.startswith("https://"): + redirected_base = redirect_host + else: + redirected_base = f"https://{redirect_host}" + if redirected_base != current_poll_base_url: + logger.info( + "QR status redirect: switching polling host to {}", + redirected_base, + ) + current_poll_base_url = redirected_base + else: + logger.warning( + "QR status returned scaned_but_redirect but redirect_host is missing", + ) elif status == "expired": refresh_count += 1 if refresh_count > MAX_QR_REFRESH_COUNT: diff --git a/tests/channels/test_weixin_channel.py b/tests/channels/test_weixin_channel.py index a52aaa80..076be610 100644 --- a/tests/channels/test_weixin_channel.py +++ b/tests/channels/test_weixin_channel.py @@ -227,8 +227,12 @@ async def test_qr_login_refreshes_expired_qr_and_then_succeeds() -> None: channel._api_get = AsyncMock( side_effect=[ {"qrcode": "qr-1", "qrcode_img_content": "url-1"}, - {"status": "expired"}, {"qrcode": "qr-2", "qrcode_img_content": "url-2"}, + ] + ) + channel._api_get_with_base = AsyncMock( + side_effect=[ + {"status": "expired"}, { "status": "confirmed", "bot_token": "token-2", @@ -254,12 +258,16 @@ async def test_qr_login_returns_false_after_too_many_expired_qr_codes() -> None: channel._api_get = AsyncMock( side_effect=[ {"qrcode": "qr-1", "qrcode_img_content": "url-1"}, - {"status": "expired"}, {"qrcode": "qr-2", "qrcode_img_content": "url-2"}, - {"status": "expired"}, {"qrcode": "qr-3", "qrcode_img_content": "url-3"}, - {"status": "expired"}, {"qrcode": "qr-4", "qrcode_img_content": "url-4"}, + ] + ) + channel._api_get_with_base = AsyncMock( + side_effect=[ + {"status": "expired"}, + {"status": "expired"}, + {"status": "expired"}, {"status": "expired"}, ] ) @@ -269,6 +277,70 @@ async def test_qr_login_returns_false_after_too_many_expired_qr_codes() -> None: assert ok is False +@pytest.mark.asyncio +async def test_qr_login_switches_polling_base_url_on_redirect_status() -> None: + channel, _bus = _make_channel() + channel._running = True + channel._save_state = lambda: None + channel._print_qr_code = lambda url: None + channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1")) + + status_side_effect = [ + {"status": "scaned_but_redirect", "redirect_host": "idc.redirect.test"}, + { + "status": "confirmed", + "bot_token": "token-3", + "ilink_bot_id": "bot-3", + "baseurl": "https://example.test", + "ilink_user_id": "wx-user", + }, + ] + channel._api_get = AsyncMock(side_effect=list(status_side_effect)) + channel._api_get_with_base = AsyncMock(side_effect=list(status_side_effect)) + + ok = await channel._qr_login() + + assert ok is True + assert channel._token == "token-3" + assert channel._api_get_with_base.await_count == 2 + first_call = channel._api_get_with_base.await_args_list[0] + second_call = channel._api_get_with_base.await_args_list[1] + assert first_call.kwargs["base_url"] == "https://ilinkai.weixin.qq.com" + assert second_call.kwargs["base_url"] == "https://idc.redirect.test" + + +@pytest.mark.asyncio +async def test_qr_login_redirect_without_host_keeps_current_polling_base_url() -> None: + channel, _bus = _make_channel() + channel._running = True + channel._save_state = lambda: None + channel._print_qr_code = lambda url: None + channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1")) + + status_side_effect = [ + {"status": "scaned_but_redirect"}, + { + "status": "confirmed", + "bot_token": "token-4", + "ilink_bot_id": "bot-4", + "baseurl": "https://example.test", + "ilink_user_id": "wx-user", + }, + ] + channel._api_get = AsyncMock(side_effect=list(status_side_effect)) + channel._api_get_with_base = AsyncMock(side_effect=list(status_side_effect)) + + ok = await channel._qr_login() + + assert ok is True + assert channel._token == "token-4" + assert channel._api_get_with_base.await_count == 2 + first_call = channel._api_get_with_base.await_args_list[0] + second_call = channel._api_get_with_base.await_args_list[1] + assert first_call.kwargs["base_url"] == "https://ilinkai.weixin.qq.com" + assert second_call.kwargs["base_url"] == "https://ilinkai.weixin.qq.com" + + @pytest.mark.asyncio async def test_process_message_skips_bot_messages() -> None: channel, bus = _make_channel() From 2abd990b893edc0da8464f59b53373b5f870d883 Mon Sep 17 00:00:00 2001 From: xcosmosbox <2162381070@qq.com> Date: Sun, 29 Mar 2026 15:19:57 +0800 Subject: [PATCH 08/91] feat(weixin): add fallback logic for referenced media download --- nanobot/channels/weixin.py | 46 +++++++++++++++++ tests/channels/test_weixin_channel.py | 74 +++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py index 51cef15e..6324290f 100644 --- a/nanobot/channels/weixin.py +++ b/nanobot/channels/weixin.py @@ -691,6 +691,52 @@ class WeixinChannel(BaseChannel): else: content_parts.append("[video]") + # Fallback: when no top-level media was downloaded, try quoted/referenced media. + # This aligns with the reference plugin behavior that checks ref_msg.message_item + # when main item_list has no downloadable media. + if not media_paths: + ref_media_item: dict[str, Any] | None = None + for item in item_list: + if item.get("type", 0) != ITEM_TEXT: + continue + ref = item.get("ref_msg") or {} + candidate = ref.get("message_item") or {} + if candidate.get("type", 0) in (ITEM_IMAGE, ITEM_VOICE, ITEM_FILE, ITEM_VIDEO): + ref_media_item = candidate + break + + if ref_media_item: + ref_type = ref_media_item.get("type", 0) + if ref_type == ITEM_IMAGE: + image_item = ref_media_item.get("image_item") or {} + file_path = await self._download_media_item(image_item, "image") + if file_path: + content_parts.append(f"[image]\n[Image: source: {file_path}]") + media_paths.append(file_path) + elif ref_type == ITEM_VOICE: + voice_item = ref_media_item.get("voice_item") or {} + file_path = await self._download_media_item(voice_item, "voice") + if file_path: + transcription = await self.transcribe_audio(file_path) + if transcription: + content_parts.append(f"[voice] {transcription}") + else: + content_parts.append(f"[voice]\n[Audio: source: {file_path}]") + media_paths.append(file_path) + elif ref_type == ITEM_FILE: + file_item = ref_media_item.get("file_item") or {} + file_name = file_item.get("file_name", "unknown") + file_path = await self._download_media_item(file_item, "file", file_name) + if file_path: + content_parts.append(f"[file: {file_name}]\n[File: source: {file_path}]") + media_paths.append(file_path) + elif ref_type == ITEM_VIDEO: + video_item = ref_media_item.get("video_item") or {} + file_path = await self._download_media_item(video_item, "video") + if file_path: + content_parts.append(f"[video]\n[Video: source: {file_path}]") + media_paths.append(file_path) + content = "\n".join(content_parts) if not content: return diff --git a/tests/channels/test_weixin_channel.py b/tests/channels/test_weixin_channel.py index 076be610..565b08b0 100644 --- a/tests/channels/test_weixin_channel.py +++ b/tests/channels/test_weixin_channel.py @@ -176,6 +176,80 @@ async def test_process_message_extracts_media_and_preserves_paths() -> None: assert inbound.media == ["/tmp/test.jpg"] +@pytest.mark.asyncio +async def test_process_message_falls_back_to_referenced_media_when_no_top_level_media() -> None: + channel, bus = _make_channel() + channel._download_media_item = AsyncMock(return_value="/tmp/ref.jpg") + + await channel._process_message( + { + "message_type": 1, + "message_id": "m3-ref-fallback", + "from_user_id": "wx-user", + "context_token": "ctx-3-ref-fallback", + "item_list": [ + { + "type": ITEM_TEXT, + "text_item": {"text": "reply to image"}, + "ref_msg": { + "message_item": { + "type": ITEM_IMAGE, + "image_item": {"media": {"encrypt_query_param": "ref-enc"}}, + }, + }, + }, + ], + } + ) + + inbound = await asyncio.wait_for(bus.consume_inbound(), timeout=1.0) + + channel._download_media_item.assert_awaited_once_with( + {"media": {"encrypt_query_param": "ref-enc"}}, + "image", + ) + assert inbound.media == ["/tmp/ref.jpg"] + assert "reply to image" in inbound.content + assert "[image]" in inbound.content + + +@pytest.mark.asyncio +async def test_process_message_does_not_use_referenced_fallback_when_top_level_media_exists() -> None: + channel, bus = _make_channel() + channel._download_media_item = AsyncMock(side_effect=["/tmp/top.jpg", "/tmp/ref.jpg"]) + + await channel._process_message( + { + "message_type": 1, + "message_id": "m3-ref-no-fallback", + "from_user_id": "wx-user", + "context_token": "ctx-3-ref-no-fallback", + "item_list": [ + {"type": ITEM_IMAGE, "image_item": {"media": {"encrypt_query_param": "top-enc"}}}, + { + "type": ITEM_TEXT, + "text_item": {"text": "has top-level media"}, + "ref_msg": { + "message_item": { + "type": ITEM_IMAGE, + "image_item": {"media": {"encrypt_query_param": "ref-enc"}}, + }, + }, + }, + ], + } + ) + + inbound = await asyncio.wait_for(bus.consume_inbound(), timeout=1.0) + + channel._download_media_item.assert_awaited_once_with( + {"media": {"encrypt_query_param": "top-enc"}}, + "image", + ) + assert inbound.media == ["/tmp/top.jpg"] + assert "/tmp/ref.jpg" not in inbound.content + + @pytest.mark.asyncio async def test_send_without_context_token_does_not_send_text() -> None: channel, _bus = _make_channel() From 79a915307ce4423e8d1daf7f6221a827b26e4478 Mon Sep 17 00:00:00 2001 From: xcosmosbox <2162381070@qq.com> Date: Sun, 29 Mar 2026 16:25:25 +0800 Subject: [PATCH 09/91] feat(weixin): implement getConfig and sendTyping --- nanobot/channels/weixin.py | 85 ++++++++++++++++++++++----- tests/channels/test_weixin_channel.py | 64 ++++++++++++++++++++ 2 files changed, 135 insertions(+), 14 deletions(-) diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py index 6324290f..eb7d218d 100644 --- a/nanobot/channels/weixin.py +++ b/nanobot/channels/weixin.py @@ -99,6 +99,9 @@ MAX_CONSECUTIVE_FAILURES = 3 BACKOFF_DELAY_S = 30 RETRY_DELAY_S = 2 MAX_QR_REFRESH_COUNT = 3 +TYPING_STATUS_TYPING = 1 +TYPING_STATUS_CANCEL = 2 +TYPING_TICKET_TTL_S = 24 * 60 * 60 # Default long-poll timeout; overridden by server via longpolling_timeout_ms. DEFAULT_LONG_POLL_TIMEOUT_S = 35 @@ -158,6 +161,7 @@ class WeixinChannel(BaseChannel): self._poll_task: asyncio.Task | None = None self._next_poll_timeout_s: int = DEFAULT_LONG_POLL_TIMEOUT_S self._session_pause_until: float = 0.0 + self._typing_tickets: dict[str, tuple[str, float]] = {} # ------------------------------------------------------------------ # State persistence @@ -832,6 +836,40 @@ class WeixinChannel(BaseChannel): # Outbound (matches send.ts buildTextMessageReq + sendMessageWeixin) # ------------------------------------------------------------------ + async def _get_typing_ticket(self, user_id: str, context_token: str = "") -> str: + """Get typing ticket for a user with simple per-user TTL cache.""" + now = time.time() + cached = self._typing_tickets.get(user_id) + if cached: + ticket, expires_at = cached + if ticket and now < expires_at: + return ticket + + body: dict[str, Any] = { + "ilink_user_id": user_id, + "context_token": context_token or None, + "base_info": BASE_INFO, + } + data = await self._api_post("ilink/bot/getconfig", body) + if data.get("ret", 0) == 0: + ticket = str(data.get("typing_ticket", "") or "") + if ticket: + self._typing_tickets[user_id] = (ticket, now + TYPING_TICKET_TTL_S) + return ticket + return "" + + async def _send_typing(self, user_id: str, typing_ticket: str, status: int) -> None: + """Best-effort sendtyping wrapper.""" + if not typing_ticket: + return + body: dict[str, Any] = { + "ilink_user_id": user_id, + "typing_ticket": typing_ticket, + "status": status, + "base_info": BASE_INFO, + } + await self._api_post("ilink/bot/sendtyping", body) + async def send(self, msg: OutboundMessage) -> None: if not self._client or not self._token: logger.warning("WeChat client not initialized or not authenticated") @@ -851,29 +889,48 @@ class WeixinChannel(BaseChannel): ) return - # --- Send media files first (following Telegram channel pattern) --- - for media_path in (msg.media or []): - try: - await self._send_media_file(msg.chat_id, media_path, ctx_token) - except Exception as e: - filename = Path(media_path).name - logger.error("Failed to send WeChat media {}: {}", media_path, e) - # Notify user about failure via text - await self._send_text( - msg.chat_id, f"[Failed to send: {filename}]", ctx_token, - ) + typing_ticket = "" + try: + typing_ticket = await self._get_typing_ticket(msg.chat_id, ctx_token) + except Exception as e: + logger.warning("WeChat getconfig failed for {}: {}", msg.chat_id, e) + typing_ticket = "" - # --- Send text content --- - if not content: - return + if typing_ticket: + try: + await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_TYPING) + except Exception as e: + logger.debug("WeChat sendtyping(start) failed for {}: {}", msg.chat_id, e) try: + # --- Send media files first (following Telegram channel pattern) --- + for media_path in (msg.media or []): + try: + await self._send_media_file(msg.chat_id, media_path, ctx_token) + except Exception as e: + filename = Path(media_path).name + logger.error("Failed to send WeChat media {}: {}", media_path, e) + # Notify user about failure via text + await self._send_text( + msg.chat_id, f"[Failed to send: {filename}]", ctx_token, + ) + + # --- Send text content --- + if not content: + return + chunks = split_message(content, WEIXIN_MAX_MESSAGE_LEN) for chunk in chunks: await self._send_text(msg.chat_id, chunk, ctx_token) except Exception as e: logger.error("Error sending WeChat message: {}", e) raise + finally: + if typing_ticket: + try: + await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_CANCEL) + except Exception as e: + logger.debug("WeChat sendtyping(cancel) failed for {}: {}", msg.chat_id, e) async def _send_text( self, diff --git a/tests/channels/test_weixin_channel.py b/tests/channels/test_weixin_channel.py index 565b08b0..64ea0b37 100644 --- a/tests/channels/test_weixin_channel.py +++ b/tests/channels/test_weixin_channel.py @@ -280,6 +280,70 @@ async def test_send_does_not_send_when_session_is_paused() -> None: channel._send_text.assert_not_awaited() +@pytest.mark.asyncio +async def test_get_typing_ticket_fetches_and_caches_per_user() -> None: + channel, _bus = _make_channel() + channel._client = object() + channel._token = "token" + channel._api_post = AsyncMock(return_value={"ret": 0, "typing_ticket": "ticket-1"}) + + first = await channel._get_typing_ticket("wx-user", "ctx-1") + second = await channel._get_typing_ticket("wx-user", "ctx-2") + + assert first == "ticket-1" + assert second == "ticket-1" + channel._api_post.assert_awaited_once_with( + "ilink/bot/getconfig", + {"ilink_user_id": "wx-user", "context_token": "ctx-1", "base_info": weixin_mod.BASE_INFO}, + ) + + +@pytest.mark.asyncio +async def test_send_uses_typing_start_and_cancel_when_ticket_available() -> None: + channel, _bus = _make_channel() + channel._client = object() + channel._token = "token" + channel._context_tokens["wx-user"] = "ctx-typing" + channel._send_text = AsyncMock() + channel._api_post = AsyncMock( + side_effect=[ + {"ret": 0, "typing_ticket": "ticket-typing"}, + {"ret": 0}, + {"ret": 0}, + ] + ) + + await channel.send( + type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})() + ) + + channel._send_text.assert_awaited_once_with("wx-user", "pong", "ctx-typing") + assert channel._api_post.await_count == 3 + assert channel._api_post.await_args_list[0].args[0] == "ilink/bot/getconfig" + assert channel._api_post.await_args_list[1].args[0] == "ilink/bot/sendtyping" + assert channel._api_post.await_args_list[1].args[1]["status"] == 1 + assert channel._api_post.await_args_list[2].args[0] == "ilink/bot/sendtyping" + assert channel._api_post.await_args_list[2].args[1]["status"] == 2 + + +@pytest.mark.asyncio +async def test_send_still_sends_text_when_typing_ticket_missing() -> None: + channel, _bus = _make_channel() + channel._client = object() + channel._token = "token" + channel._context_tokens["wx-user"] = "ctx-no-ticket" + channel._send_text = AsyncMock() + channel._api_post = AsyncMock(return_value={"ret": 1, "errmsg": "no config"}) + + await channel.send( + type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})() + ) + + channel._send_text.assert_awaited_once_with("wx-user", "pong", "ctx-no-ticket") + channel._api_post.assert_awaited_once() + assert channel._api_post.await_args_list[0].args[0] == "ilink/bot/getconfig" + + @pytest.mark.asyncio async def test_poll_once_pauses_session_on_expired_errcode() -> None: channel, _bus = _make_channel() From ed2ca759e7b2b0c54247fb5485fcbf87c725abee Mon Sep 17 00:00:00 2001 From: xcosmosbox <2162381070@qq.com> Date: Sun, 29 Mar 2026 20:27:23 +0800 Subject: [PATCH 10/91] fix(weixin): align full_url AES key handling and quoted media fallback logic with reference 1. Fix full_url path for non-image media to require AES key and skip download when missing, instead of persisting encrypted bytes as valid media. 2. Restrict quoted media fallback trigger to only when no top-level media item exists, not when top-level media download/decryption fails. --- nanobot/channels/weixin.py | 23 +++++++++- tests/channels/test_weixin_channel.py | 61 +++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py index eb7d218d..74d3a473 100644 --- a/nanobot/channels/weixin.py +++ b/nanobot/channels/weixin.py @@ -116,6 +116,12 @@ _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff", ".ico" _VIDEO_EXTS = {".mp4", ".avi", ".mov", ".mkv", ".webm", ".flv"} +def _has_downloadable_media_locator(media: dict[str, Any] | None) -> bool: + if not isinstance(media, dict): + return False + return bool(str(media.get("encrypt_query_param", "") or "") or str(media.get("full_url", "") or "").strip()) + + class WeixinConfig(Base): """Personal WeChat channel configuration.""" @@ -611,6 +617,7 @@ class WeixinChannel(BaseChannel): item_list: list[dict] = msg.get("item_list") or [] content_parts: list[str] = [] media_paths: list[str] = [] + has_top_level_downloadable_media = False for item in item_list: item_type = item.get("type", 0) @@ -647,6 +654,8 @@ class WeixinChannel(BaseChannel): elif item_type == ITEM_IMAGE: image_item = item.get("image_item") or {} + if _has_downloadable_media_locator(image_item.get("media")): + has_top_level_downloadable_media = True file_path = await self._download_media_item(image_item, "image") if file_path: content_parts.append(f"[image]\n[Image: source: {file_path}]") @@ -661,6 +670,8 @@ class WeixinChannel(BaseChannel): if voice_text: content_parts.append(f"[voice] {voice_text}") else: + if _has_downloadable_media_locator(voice_item.get("media")): + has_top_level_downloadable_media = True file_path = await self._download_media_item(voice_item, "voice") if file_path: transcription = await self.transcribe_audio(file_path) @@ -674,6 +685,8 @@ class WeixinChannel(BaseChannel): elif item_type == ITEM_FILE: file_item = item.get("file_item") or {} + if _has_downloadable_media_locator(file_item.get("media")): + has_top_level_downloadable_media = True file_name = file_item.get("file_name", "unknown") file_path = await self._download_media_item( file_item, @@ -688,6 +701,8 @@ class WeixinChannel(BaseChannel): elif item_type == ITEM_VIDEO: video_item = item.get("video_item") or {} + if _has_downloadable_media_locator(video_item.get("media")): + has_top_level_downloadable_media = True file_path = await self._download_media_item(video_item, "video") if file_path: content_parts.append(f"[video]\n[Video: source: {file_path}]") @@ -698,7 +713,7 @@ class WeixinChannel(BaseChannel): # Fallback: when no top-level media was downloaded, try quoted/referenced media. # This aligns with the reference plugin behavior that checks ref_msg.message_item # when main item_list has no downloadable media. - if not media_paths: + if not media_paths and not has_top_level_downloadable_media: ref_media_item: dict[str, Any] | None = None for item in item_list: if item.get("type", 0) != ITEM_TEXT: @@ -793,6 +808,12 @@ class WeixinChannel(BaseChannel): elif media_aes_key_b64: aes_key_b64 = media_aes_key_b64 + # Reference protocol behavior: VOICE/FILE/VIDEO require aes_key; + # only IMAGE may be downloaded as plain bytes when key is missing. + if media_type != "image" and not aes_key_b64: + logger.debug("Missing AES key for {} item, skip media download", media_type) + return None + # Prefer server-provided full_url, fallback to encrypted_query_param URL construction. if full_url: cdn_url = full_url diff --git a/tests/channels/test_weixin_channel.py b/tests/channels/test_weixin_channel.py index 64ea0b37..7701ad59 100644 --- a/tests/channels/test_weixin_channel.py +++ b/tests/channels/test_weixin_channel.py @@ -250,6 +250,46 @@ async def test_process_message_does_not_use_referenced_fallback_when_top_level_m assert "/tmp/ref.jpg" not in inbound.content +@pytest.mark.asyncio +async def test_process_message_does_not_fallback_when_top_level_media_exists_but_download_fails() -> None: + channel, bus = _make_channel() + # Top-level image download fails (None), referenced image would succeed if fallback were triggered. + channel._download_media_item = AsyncMock(side_effect=[None, "/tmp/ref.jpg"]) + + await channel._process_message( + { + "message_type": 1, + "message_id": "m3-ref-no-fallback-on-failure", + "from_user_id": "wx-user", + "context_token": "ctx-3-ref-no-fallback-on-failure", + "item_list": [ + {"type": ITEM_IMAGE, "image_item": {"media": {"encrypt_query_param": "top-enc"}}}, + { + "type": ITEM_TEXT, + "text_item": {"text": "quoted has media"}, + "ref_msg": { + "message_item": { + "type": ITEM_IMAGE, + "image_item": {"media": {"encrypt_query_param": "ref-enc"}}, + }, + }, + }, + ], + } + ) + + inbound = await asyncio.wait_for(bus.consume_inbound(), timeout=1.0) + + # Should only attempt top-level media item; reference fallback must not activate. + channel._download_media_item.assert_awaited_once_with( + {"media": {"encrypt_query_param": "top-enc"}}, + "image", + ) + assert inbound.media == [] + assert "[image]" in inbound.content + assert "/tmp/ref.jpg" not in inbound.content + + @pytest.mark.asyncio async def test_send_without_context_token_does_not_send_text() -> None: channel, _bus = _make_channel() @@ -613,3 +653,24 @@ async def test_download_media_item_falls_back_to_encrypt_query_param(tmp_path) - assert Path(saved_path).read_bytes() == b"fallback-bytes" called_url = channel._client.get.await_args_list[0].args[0] assert called_url.startswith(f"{channel.config.cdn_base_url}/download?encrypted_query_param=enc-fallback") + + +@pytest.mark.asyncio +async def test_download_media_item_non_image_requires_aes_key_even_with_full_url(tmp_path) -> None: + channel, _bus = _make_channel() + weixin_mod.get_media_dir = lambda _name: tmp_path + + full_url = "https://cdn.example.test/download/voice" + channel._client = SimpleNamespace( + get=AsyncMock(return_value=_DummyDownloadResponse(content=b"ciphertext-or-unknown")) + ) + + item = { + "media": { + "full_url": full_url, + }, + } + saved_path = await channel._download_media_item(item, "voice") + + assert saved_path is None + channel._client.get.assert_not_awaited() From 1a4ad676285366a8e74ba22839421b61061c7039 Mon Sep 17 00:00:00 2001 From: xcosmosbox <2162381070@qq.com> Date: Sun, 29 Mar 2026 21:28:58 +0800 Subject: [PATCH 11/91] feat(weixin): add voice message, typing keepalive, getConfig cache, and QR polling resilience --- nanobot/channels/weixin.py | 94 ++++++++++++++-- tests/channels/test_weixin_channel.py | 153 ++++++++++++++++++++++++++ 2 files changed, 235 insertions(+), 12 deletions(-) diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py index 74d3a473..4341f21d 100644 --- a/nanobot/channels/weixin.py +++ b/nanobot/channels/weixin.py @@ -15,6 +15,7 @@ import hashlib import json import mimetypes import os +import random import re import time import uuid @@ -102,18 +103,23 @@ MAX_QR_REFRESH_COUNT = 3 TYPING_STATUS_TYPING = 1 TYPING_STATUS_CANCEL = 2 TYPING_TICKET_TTL_S = 24 * 60 * 60 +TYPING_KEEPALIVE_INTERVAL_S = 5 +CONFIG_CACHE_INITIAL_RETRY_S = 2 +CONFIG_CACHE_MAX_RETRY_S = 60 * 60 # Default long-poll timeout; overridden by server via longpolling_timeout_ms. DEFAULT_LONG_POLL_TIMEOUT_S = 35 -# Media-type codes for getuploadurl (1=image, 2=video, 3=file) +# Media-type codes for getuploadurl (1=image, 2=video, 3=file, 4=voice) UPLOAD_MEDIA_IMAGE = 1 UPLOAD_MEDIA_VIDEO = 2 UPLOAD_MEDIA_FILE = 3 +UPLOAD_MEDIA_VOICE = 4 # File extensions considered as images / videos for outbound media _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff", ".ico", ".svg"} _VIDEO_EXTS = {".mp4", ".avi", ".mov", ".mkv", ".webm", ".flv"} +_VOICE_EXTS = {".mp3", ".wav", ".amr", ".silk", ".ogg", ".m4a", ".aac", ".flac"} def _has_downloadable_media_locator(media: dict[str, Any] | None) -> bool: @@ -167,7 +173,7 @@ class WeixinChannel(BaseChannel): self._poll_task: asyncio.Task | None = None self._next_poll_timeout_s: int = DEFAULT_LONG_POLL_TIMEOUT_S self._session_pause_until: float = 0.0 - self._typing_tickets: dict[str, tuple[str, float]] = {} + self._typing_tickets: dict[str, dict[str, Any]] = {} # ------------------------------------------------------------------ # State persistence @@ -339,7 +345,16 @@ class WeixinChannel(BaseChannel): params={"qrcode": qrcode_id}, auth=False, ) - except httpx.TimeoutException: + except Exception as e: + if self._is_retryable_qr_poll_error(e): + logger.warning("QR polling temporary error, will retry: {}", e) + await asyncio.sleep(1) + continue + raise + + if not isinstance(status_data, dict): + logger.warning("QR polling got non-object response, continue waiting") + await asyncio.sleep(1) continue status = status_data.get("status", "") @@ -408,6 +423,16 @@ class WeixinChannel(BaseChannel): return False + @staticmethod + def _is_retryable_qr_poll_error(err: Exception) -> bool: + if isinstance(err, httpx.TimeoutException | httpx.TransportError): + return True + if isinstance(err, httpx.HTTPStatusError): + status_code = err.response.status_code if err.response is not None else 0 + if status_code >= 500: + return True + return False + @staticmethod def _print_qr_code(url: str) -> None: try: @@ -858,13 +883,11 @@ class WeixinChannel(BaseChannel): # ------------------------------------------------------------------ async def _get_typing_ticket(self, user_id: str, context_token: str = "") -> str: - """Get typing ticket for a user with simple per-user TTL cache.""" + """Get typing ticket with per-user refresh + failure backoff cache.""" now = time.time() - cached = self._typing_tickets.get(user_id) - if cached: - ticket, expires_at = cached - if ticket and now < expires_at: - return ticket + entry = self._typing_tickets.get(user_id) + if entry and now < float(entry.get("next_fetch_at", 0)): + return str(entry.get("ticket", "") or "") body: dict[str, Any] = { "ilink_user_id": user_id, @@ -874,9 +897,27 @@ class WeixinChannel(BaseChannel): data = await self._api_post("ilink/bot/getconfig", body) if data.get("ret", 0) == 0: ticket = str(data.get("typing_ticket", "") or "") - if ticket: - self._typing_tickets[user_id] = (ticket, now + TYPING_TICKET_TTL_S) - return ticket + self._typing_tickets[user_id] = { + "ticket": ticket, + "ever_succeeded": True, + "next_fetch_at": now + (random.random() * TYPING_TICKET_TTL_S), + "retry_delay_s": CONFIG_CACHE_INITIAL_RETRY_S, + } + return ticket + + prev_delay = float(entry.get("retry_delay_s", CONFIG_CACHE_INITIAL_RETRY_S)) if entry else CONFIG_CACHE_INITIAL_RETRY_S + next_delay = min(prev_delay * 2, CONFIG_CACHE_MAX_RETRY_S) + if entry: + entry["next_fetch_at"] = now + next_delay + entry["retry_delay_s"] = next_delay + return str(entry.get("ticket", "") or "") + + self._typing_tickets[user_id] = { + "ticket": "", + "ever_succeeded": False, + "next_fetch_at": now + CONFIG_CACHE_INITIAL_RETRY_S, + "retry_delay_s": CONFIG_CACHE_INITIAL_RETRY_S, + } return "" async def _send_typing(self, user_id: str, typing_ticket: str, status: int) -> None: @@ -891,6 +932,16 @@ class WeixinChannel(BaseChannel): } await self._api_post("ilink/bot/sendtyping", body) + async def _typing_keepalive_loop(self, user_id: str, typing_ticket: str, stop_event: asyncio.Event) -> None: + while not stop_event.is_set(): + await asyncio.sleep(TYPING_KEEPALIVE_INTERVAL_S) + if stop_event.is_set(): + break + try: + await self._send_typing(user_id, typing_ticket, TYPING_STATUS_TYPING) + except Exception as e: + logger.debug("WeChat sendtyping(keepalive) failed for {}: {}", user_id, e) + async def send(self, msg: OutboundMessage) -> None: if not self._client or not self._token: logger.warning("WeChat client not initialized or not authenticated") @@ -923,6 +974,13 @@ class WeixinChannel(BaseChannel): except Exception as e: logger.debug("WeChat sendtyping(start) failed for {}: {}", msg.chat_id, e) + typing_keepalive_stop = asyncio.Event() + typing_keepalive_task: asyncio.Task | None = None + if typing_ticket: + typing_keepalive_task = asyncio.create_task( + self._typing_keepalive_loop(msg.chat_id, typing_ticket, typing_keepalive_stop) + ) + try: # --- Send media files first (following Telegram channel pattern) --- for media_path in (msg.media or []): @@ -947,6 +1005,14 @@ class WeixinChannel(BaseChannel): logger.error("Error sending WeChat message: {}", e) raise finally: + if typing_keepalive_task: + typing_keepalive_stop.set() + typing_keepalive_task.cancel() + try: + await typing_keepalive_task + except asyncio.CancelledError: + pass + if typing_ticket: try: await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_CANCEL) @@ -1025,6 +1091,10 @@ class WeixinChannel(BaseChannel): upload_type = UPLOAD_MEDIA_VIDEO item_type = ITEM_VIDEO item_key = "video_item" + elif ext in _VOICE_EXTS: + upload_type = UPLOAD_MEDIA_VOICE + item_type = ITEM_VOICE + item_key = "voice_item" else: upload_type = UPLOAD_MEDIA_FILE item_type = ITEM_FILE diff --git a/tests/channels/test_weixin_channel.py b/tests/channels/test_weixin_channel.py index 7701ad59..c4e5cf55 100644 --- a/tests/channels/test_weixin_channel.py +++ b/tests/channels/test_weixin_channel.py @@ -6,6 +6,7 @@ from types import SimpleNamespace from unittest.mock import AsyncMock import pytest +import httpx import nanobot.channels.weixin as weixin_mod from nanobot.bus.queue import MessageBus @@ -595,6 +596,158 @@ async def test_send_media_falls_back_to_upload_param_url(tmp_path) -> None: assert "&filekey=" in cdn_url +@pytest.mark.asyncio +async def test_send_media_voice_file_uses_voice_item_and_voice_upload_type(tmp_path) -> None: + channel, _bus = _make_channel() + + media_file = tmp_path / "voice.mp3" + media_file.write_bytes(b"voice-bytes") + + cdn_post = AsyncMock(return_value=_DummyHttpResponse(headers={"x-encrypted-param": "voice-dl-param"})) + channel._client = SimpleNamespace(post=cdn_post) + channel._api_post = AsyncMock( + side_effect=[ + {"upload_full_url": "https://upload-full.example.test/voice?foo=bar"}, + {"ret": 0}, + ] + ) + + await channel._send_media_file("wx-user", str(media_file), "ctx-voice") + + getupload_body = channel._api_post.await_args_list[0].args[1] + assert getupload_body["media_type"] == 4 + + sendmessage_body = channel._api_post.await_args_list[1].args[1] + item = sendmessage_body["msg"]["item_list"][0] + assert item["type"] == 3 + assert "voice_item" in item + assert "file_item" not in item + assert item["voice_item"]["media"]["encrypt_query_param"] == "voice-dl-param" + + +@pytest.mark.asyncio +async def test_send_typing_uses_keepalive_until_send_finishes() -> None: + channel, _bus = _make_channel() + channel._client = object() + channel._token = "token" + channel._context_tokens["wx-user"] = "ctx-typing-loop" + async def _api_post_side_effect(endpoint: str, _body: dict | None = None, *, auth: bool = True): + if endpoint == "ilink/bot/getconfig": + return {"ret": 0, "typing_ticket": "ticket-keepalive"} + return {"ret": 0} + + channel._api_post = AsyncMock(side_effect=_api_post_side_effect) + + async def _slow_send_text(*_args, **_kwargs) -> None: + await asyncio.sleep(0.03) + + channel._send_text = AsyncMock(side_effect=_slow_send_text) + + old_interval = weixin_mod.TYPING_KEEPALIVE_INTERVAL_S + weixin_mod.TYPING_KEEPALIVE_INTERVAL_S = 0.01 + try: + await channel.send( + type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})() + ) + finally: + weixin_mod.TYPING_KEEPALIVE_INTERVAL_S = old_interval + + status_calls = [ + c.args[1]["status"] + for c in channel._api_post.await_args_list + if c.args and c.args[0] == "ilink/bot/sendtyping" + ] + assert status_calls.count(1) >= 2 + assert status_calls[-1] == 2 + + +@pytest.mark.asyncio +async def test_get_typing_ticket_failure_uses_backoff_and_cached_ticket(monkeypatch) -> None: + channel, _bus = _make_channel() + channel._client = object() + channel._token = "token" + + now = {"value": 1000.0} + monkeypatch.setattr(weixin_mod.time, "time", lambda: now["value"]) + monkeypatch.setattr(weixin_mod.random, "random", lambda: 0.5) + + channel._api_post = AsyncMock(return_value={"ret": 0, "typing_ticket": "ticket-ok"}) + first = await channel._get_typing_ticket("wx-user", "ctx-1") + assert first == "ticket-ok" + + # force refresh window reached + now["value"] = now["value"] + (12 * 60 * 60) + 1 + channel._api_post = AsyncMock(return_value={"ret": 1, "errmsg": "temporary failure"}) + + # On refresh failure, should still return cached ticket and apply backoff. + second = await channel._get_typing_ticket("wx-user", "ctx-2") + assert second == "ticket-ok" + assert channel._api_post.await_count == 1 + + # Before backoff expiry, no extra fetch should happen. + now["value"] += 1 + third = await channel._get_typing_ticket("wx-user", "ctx-3") + assert third == "ticket-ok" + assert channel._api_post.await_count == 1 + + +@pytest.mark.asyncio +async def test_qr_login_treats_temporary_connect_error_as_wait_and_recovers() -> None: + channel, _bus = _make_channel() + channel._running = True + channel._save_state = lambda: None + channel._print_qr_code = lambda url: None + channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1")) + + request = httpx.Request("GET", "https://ilinkai.weixin.qq.com/ilink/bot/get_qrcode_status") + channel._api_get_with_base = AsyncMock( + side_effect=[ + httpx.ConnectError("temporary network", request=request), + { + "status": "confirmed", + "bot_token": "token-net-ok", + "ilink_bot_id": "bot-id", + "baseurl": "https://example.test", + "ilink_user_id": "wx-user", + }, + ] + ) + + ok = await channel._qr_login() + + assert ok is True + assert channel._token == "token-net-ok" + + +@pytest.mark.asyncio +async def test_qr_login_treats_5xx_gateway_response_error_as_wait_and_recovers() -> None: + channel, _bus = _make_channel() + channel._running = True + channel._save_state = lambda: None + channel._print_qr_code = lambda url: None + channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1")) + + request = httpx.Request("GET", "https://ilinkai.weixin.qq.com/ilink/bot/get_qrcode_status") + response = httpx.Response(status_code=524, request=request) + channel._api_get_with_base = AsyncMock( + side_effect=[ + httpx.HTTPStatusError("gateway timeout", request=request, response=response), + { + "status": "confirmed", + "bot_token": "token-5xx-ok", + "ilink_bot_id": "bot-id", + "baseurl": "https://example.test", + "ilink_user_id": "wx-user", + }, + ] + ) + + ok = await channel._qr_login() + + assert ok is True + assert channel._token == "token-5xx-ok" + + def test_decrypt_aes_ecb_strips_valid_pkcs7_padding() -> None: key_b64 = "MDEyMzQ1Njc4OWFiY2RlZg==" # base64("0123456789abcdef") plaintext = b"hello-weixin-padding" From 5635907e3318f16979c2833bb1fc2b2a0c9b6aab Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sun, 29 Mar 2026 15:32:33 +0000 Subject: [PATCH 12/91] feat(api): load serve settings from config Read serve host, port, and timeout from config by default, keep CLI flags higher priority, and bind the API to localhost by default for safer local usage. --- nanobot/api/server.py | 2 +- nanobot/cli/commands.py | 15 ++- nanobot/config/schema.py | 9 ++ tests/cli/test_commands.py | 262 ++++++++++++++++++++++++++----------- 4 files changed, 206 insertions(+), 82 deletions(-) diff --git a/nanobot/api/server.py b/nanobot/api/server.py index 1dd58d51..2a818667 100644 --- a/nanobot/api/server.py +++ b/nanobot/api/server.py @@ -192,7 +192,7 @@ def create_app(agent_loop, model_name: str = "nanobot", request_timeout: float = return app -def run_server(agent_loop, host: str = "0.0.0.0", port: int = 8900, +def run_server(agent_loop, host: str = "127.0.0.1", port: int = 8900, model_name: str = "nanobot", request_timeout: float = 120.0) -> None: """Create and run the server (blocking).""" app = create_app(agent_loop, model_name=model_name, request_timeout=request_timeout) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index d3fc68e8..7f7d24f3 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -498,9 +498,9 @@ def _migrate_cron_store(config: "Config") -> None: @app.command() def serve( - port: int = typer.Option(8900, "--port", "-p", help="API server port"), - host: str = typer.Option("0.0.0.0", "--host", "-H", help="Bind address"), - timeout: float = typer.Option(120.0, "--timeout", "-t", help="Per-request timeout (seconds)"), + port: int | None = typer.Option(None, "--port", "-p", help="API server port"), + host: str | None = typer.Option(None, "--host", "-H", help="Bind address"), + timeout: float | None = typer.Option(None, "--timeout", "-t", help="Per-request timeout (seconds)"), verbose: bool = typer.Option(False, "--verbose", "-v", help="Show nanobot runtime logs"), workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"), config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"), @@ -524,6 +524,10 @@ def serve( logger.disable("nanobot") runtime_config = _load_runtime_config(config, workspace) + api_cfg = runtime_config.api + host = host if host is not None else api_cfg.host + port = port if port is not None else api_cfg.port + timeout = timeout if timeout is not None else api_cfg.timeout sync_workspace_templates(runtime_config.workspace_path) bus = MessageBus() provider = _make_provider(runtime_config) @@ -551,6 +555,11 @@ def serve( console.print(f" [cyan]Model[/cyan] : {model_name}") console.print(" [cyan]Session[/cyan] : api:default") console.print(f" [cyan]Timeout[/cyan] : {timeout}s") + if host in {"0.0.0.0", "::"}: + console.print( + "[yellow]Warning:[/yellow] API is bound to all interfaces. " + "Only do this behind a trusted network boundary, firewall, or reverse proxy." + ) console.print() api_app = create_app(agent_loop, model_name=model_name, request_timeout=timeout) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index c8b69b42..c4c927af 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -96,6 +96,14 @@ class HeartbeatConfig(Base): keep_recent_messages: int = 8 +class ApiConfig(Base): + """OpenAI-compatible API server configuration.""" + + host: str = "127.0.0.1" # Safer default: local-only bind. + port: int = 8900 + timeout: float = 120.0 # Per-request timeout in seconds. + + class GatewayConfig(Base): """Gateway/server configuration.""" @@ -156,6 +164,7 @@ class Config(BaseSettings): agents: AgentsConfig = Field(default_factory=AgentsConfig) channels: ChannelsConfig = Field(default_factory=ChannelsConfig) providers: ProvidersConfig = Field(default_factory=ProvidersConfig) + api: ApiConfig = Field(default_factory=ApiConfig) gateway: GatewayConfig = Field(default_factory=GatewayConfig) tools: ToolsConfig = Field(default_factory=ToolsConfig) diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index a8fcc4aa..735c02a5 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -642,27 +642,105 @@ def test_heartbeat_retains_recent_messages_by_default(): assert config.gateway.heartbeat.keep_recent_messages == 8 -def test_gateway_uses_workspace_from_config_by_default(monkeypatch, tmp_path: Path) -> None: +def _write_instance_config(tmp_path: Path) -> Path: config_file = tmp_path / "instance" / "config.json" config_file.parent.mkdir(parents=True) config_file.write_text("{}") + return config_file - config = Config() - config.agents.defaults.workspace = str(tmp_path / "config-workspace") - seen: dict[str, Path] = {} +def _stop_gateway_provider(_config) -> object: + raise _StopGatewayError("stop") + + +def _patch_cli_command_runtime( + monkeypatch, + config: Config, + *, + set_config_path=None, + sync_templates=None, + make_provider=None, + message_bus=None, + session_manager=None, + cron_service=None, + get_cron_dir=None, +) -> None: monkeypatch.setattr( "nanobot.config.loader.set_config_path", - lambda path: seen.__setitem__("config_path", path), + set_config_path or (lambda _path: None), ) monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) monkeypatch.setattr( "nanobot.cli.commands.sync_workspace_templates", - lambda path: seen.__setitem__("workspace", path), + sync_templates or (lambda _path: None), ) monkeypatch.setattr( "nanobot.cli.commands._make_provider", - lambda _config: (_ for _ in ()).throw(_StopGatewayError("stop")), + make_provider or (lambda _config: object()), + ) + + if message_bus is not None: + monkeypatch.setattr("nanobot.bus.queue.MessageBus", message_bus) + if session_manager is not None: + monkeypatch.setattr("nanobot.session.manager.SessionManager", session_manager) + if cron_service is not None: + monkeypatch.setattr("nanobot.cron.service.CronService", cron_service) + if get_cron_dir is not None: + monkeypatch.setattr("nanobot.config.paths.get_cron_dir", get_cron_dir) + + +def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -> None: + pytest.importorskip("aiohttp") + + class _FakeApiApp: + def __init__(self) -> None: + self.on_startup: list[object] = [] + self.on_cleanup: list[object] = [] + + class _FakeAgentLoop: + def __init__(self, **kwargs) -> None: + seen["workspace"] = kwargs["workspace"] + + async def _connect_mcp(self) -> None: + return None + + async def close_mcp(self) -> None: + return None + + def _fake_create_app(agent_loop, model_name: str, request_timeout: float): + seen["agent_loop"] = agent_loop + seen["model_name"] = model_name + seen["request_timeout"] = request_timeout + return _FakeApiApp() + + def _fake_run_app(api_app, host: str, port: int, print): + seen["api_app"] = api_app + seen["host"] = host + seen["port"] = port + + _patch_cli_command_runtime( + monkeypatch, + config, + message_bus=lambda: object(), + session_manager=lambda _workspace: object(), + ) + monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop) + monkeypatch.setattr("nanobot.api.server.create_app", _fake_create_app) + monkeypatch.setattr("aiohttp.web.run_app", _fake_run_app) + + +def test_gateway_uses_workspace_from_config_by_default(monkeypatch, tmp_path: Path) -> None: + config_file = _write_instance_config(tmp_path) + config = Config() + config.agents.defaults.workspace = str(tmp_path / "config-workspace") + seen: dict[str, Path] = {} + + _patch_cli_command_runtime( + monkeypatch, + config, + set_config_path=lambda path: seen.__setitem__("config_path", path), + sync_templates=lambda path: seen.__setitem__("workspace", path), + make_provider=_stop_gateway_provider, ) result = runner.invoke(app, ["gateway", "--config", str(config_file)]) @@ -673,24 +751,17 @@ def test_gateway_uses_workspace_from_config_by_default(monkeypatch, tmp_path: Pa def test_gateway_workspace_option_overrides_config(monkeypatch, tmp_path: Path) -> None: - config_file = tmp_path / "instance" / "config.json" - config_file.parent.mkdir(parents=True) - config_file.write_text("{}") - + config_file = _write_instance_config(tmp_path) config = Config() config.agents.defaults.workspace = str(tmp_path / "config-workspace") override = tmp_path / "override-workspace" seen: dict[str, Path] = {} - monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) - monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) - monkeypatch.setattr( - "nanobot.cli.commands.sync_workspace_templates", - lambda path: seen.__setitem__("workspace", path), - ) - monkeypatch.setattr( - "nanobot.cli.commands._make_provider", - lambda _config: (_ for _ in ()).throw(_StopGatewayError("stop")), + _patch_cli_command_runtime( + monkeypatch, + config, + sync_templates=lambda path: seen.__setitem__("workspace", path), + make_provider=_stop_gateway_provider, ) result = runner.invoke( @@ -704,27 +775,23 @@ def test_gateway_workspace_option_overrides_config(monkeypatch, tmp_path: Path) def test_gateway_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Path) -> None: - config_file = tmp_path / "instance" / "config.json" - config_file.parent.mkdir(parents=True) - config_file.write_text("{}") - + config_file = _write_instance_config(tmp_path) config = Config() config.agents.defaults.workspace = str(tmp_path / "config-workspace") seen: dict[str, Path] = {} - monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) - monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) - monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) - monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object()) - monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object()) - monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object()) - class _StopCron: def __init__(self, store_path: Path) -> None: seen["cron_store"] = store_path raise _StopGatewayError("stop") - monkeypatch.setattr("nanobot.cron.service.CronService", _StopCron) + _patch_cli_command_runtime( + monkeypatch, + config, + message_bus=lambda: object(), + session_manager=lambda _workspace: object(), + cron_service=_StopCron, + ) result = runner.invoke(app, ["gateway", "--config", str(config_file)]) @@ -735,10 +802,7 @@ def test_gateway_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: def test_gateway_workspace_override_does_not_migrate_legacy_cron( monkeypatch, tmp_path: Path ) -> None: - config_file = tmp_path / "instance" / "config.json" - config_file.parent.mkdir(parents=True) - config_file.write_text("{}") - + config_file = _write_instance_config(tmp_path) legacy_dir = tmp_path / "global" / "cron" legacy_dir.mkdir(parents=True) legacy_file = legacy_dir / "jobs.json" @@ -748,20 +812,19 @@ def test_gateway_workspace_override_does_not_migrate_legacy_cron( config = Config() seen: dict[str, Path] = {} - monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) - monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) - monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) - monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object()) - monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object()) - monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object()) - monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir) - class _StopCron: def __init__(self, store_path: Path) -> None: seen["cron_store"] = store_path raise _StopGatewayError("stop") - monkeypatch.setattr("nanobot.cron.service.CronService", _StopCron) + _patch_cli_command_runtime( + monkeypatch, + config, + message_bus=lambda: object(), + session_manager=lambda _workspace: object(), + cron_service=_StopCron, + get_cron_dir=lambda: legacy_dir, + ) result = runner.invoke( app, @@ -777,10 +840,7 @@ def test_gateway_workspace_override_does_not_migrate_legacy_cron( def test_gateway_custom_config_workspace_does_not_migrate_legacy_cron( monkeypatch, tmp_path: Path ) -> None: - config_file = tmp_path / "instance" / "config.json" - config_file.parent.mkdir(parents=True) - config_file.write_text("{}") - + config_file = _write_instance_config(tmp_path) legacy_dir = tmp_path / "global" / "cron" legacy_dir.mkdir(parents=True) legacy_file = legacy_dir / "jobs.json" @@ -791,20 +851,19 @@ def test_gateway_custom_config_workspace_does_not_migrate_legacy_cron( config.agents.defaults.workspace = str(custom_workspace) seen: dict[str, Path] = {} - monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) - monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) - monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) - monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object()) - monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object()) - monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object()) - monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir) - class _StopCron: def __init__(self, store_path: Path) -> None: seen["cron_store"] = store_path raise _StopGatewayError("stop") - monkeypatch.setattr("nanobot.cron.service.CronService", _StopCron) + _patch_cli_command_runtime( + monkeypatch, + config, + message_bus=lambda: object(), + session_manager=lambda _workspace: object(), + cron_service=_StopCron, + get_cron_dir=lambda: legacy_dir, + ) result = runner.invoke(app, ["gateway", "--config", str(config_file)]) @@ -856,19 +915,14 @@ def test_migrate_cron_store_skips_when_workspace_file_exists(tmp_path: Path) -> def test_gateway_uses_configured_port_when_cli_flag_is_missing(monkeypatch, tmp_path: Path) -> None: - config_file = tmp_path / "instance" / "config.json" - config_file.parent.mkdir(parents=True) - config_file.write_text("{}") - + config_file = _write_instance_config(tmp_path) config = Config() config.gateway.port = 18791 - monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) - monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) - monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) - monkeypatch.setattr( - "nanobot.cli.commands._make_provider", - lambda _config: (_ for _ in ()).throw(_StopGatewayError("stop")), + _patch_cli_command_runtime( + monkeypatch, + config, + make_provider=_stop_gateway_provider, ) result = runner.invoke(app, ["gateway", "--config", str(config_file)]) @@ -878,19 +932,14 @@ def test_gateway_uses_configured_port_when_cli_flag_is_missing(monkeypatch, tmp_ def test_gateway_cli_port_overrides_configured_port(monkeypatch, tmp_path: Path) -> None: - config_file = tmp_path / "instance" / "config.json" - config_file.parent.mkdir(parents=True) - config_file.write_text("{}") - + config_file = _write_instance_config(tmp_path) config = Config() config.gateway.port = 18791 - monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) - monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) - monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) - monkeypatch.setattr( - "nanobot.cli.commands._make_provider", - lambda _config: (_ for _ in ()).throw(_StopGatewayError("stop")), + _patch_cli_command_runtime( + monkeypatch, + config, + make_provider=_stop_gateway_provider, ) result = runner.invoke(app, ["gateway", "--config", str(config_file), "--port", "18792"]) @@ -899,6 +948,63 @@ def test_gateway_cli_port_overrides_configured_port(monkeypatch, tmp_path: Path) assert "port 18792" in result.stdout +def test_serve_uses_api_config_defaults_and_workspace_override( + monkeypatch, tmp_path: Path +) -> None: + config_file = _write_instance_config(tmp_path) + config = Config() + config.agents.defaults.workspace = str(tmp_path / "config-workspace") + config.api.host = "127.0.0.2" + config.api.port = 18900 + config.api.timeout = 45.0 + override_workspace = tmp_path / "override-workspace" + seen: dict[str, object] = {} + + _patch_serve_runtime(monkeypatch, config, seen) + + result = runner.invoke( + app, + ["serve", "--config", str(config_file), "--workspace", str(override_workspace)], + ) + + assert result.exit_code == 0 + assert seen["workspace"] == override_workspace + assert seen["host"] == "127.0.0.2" + assert seen["port"] == 18900 + assert seen["request_timeout"] == 45.0 + + +def test_serve_cli_options_override_api_config(monkeypatch, tmp_path: Path) -> None: + config_file = _write_instance_config(tmp_path) + config = Config() + config.api.host = "127.0.0.2" + config.api.port = 18900 + config.api.timeout = 45.0 + seen: dict[str, object] = {} + + _patch_serve_runtime(monkeypatch, config, seen) + + result = runner.invoke( + app, + [ + "serve", + "--config", + str(config_file), + "--host", + "127.0.0.1", + "--port", + "18901", + "--timeout", + "46", + ], + ) + + assert result.exit_code == 0 + assert seen["host"] == "127.0.0.1" + assert seen["port"] == 18901 + assert seen["request_timeout"] == 46.0 + + def test_channels_login_requires_channel_name() -> None: result = runner.invoke(app, ["channels", "login"]) From 2dce5e07c1db40e28260ec148fefeb1162e025a8 Mon Sep 17 00:00:00 2001 From: xcosmosbox <2162381070@qq.com> Date: Mon, 30 Mar 2026 09:06:49 +0800 Subject: [PATCH 13/91] fix(weixin): fix test file version reader --- nanobot/channels/weixin.py | 21 +++------------------ tests/channels/test_weixin_channel.py | 3 +-- 2 files changed, 4 insertions(+), 20 deletions(-) diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py index 4341f21d..7f6c6aba 100644 --- a/nanobot/channels/weixin.py +++ b/nanobot/channels/weixin.py @@ -54,19 +54,8 @@ MESSAGE_TYPE_BOT = 2 MESSAGE_STATE_FINISH = 2 WEIXIN_MAX_MESSAGE_LEN = 4000 - - -def _read_reference_package_meta() -> dict[str, str]: - """Best-effort read of reference `package/package.json` metadata.""" - try: - pkg_path = Path(__file__).resolve().parents[2] / "package" / "package.json" - data = json.loads(pkg_path.read_text(encoding="utf-8")) - return { - "version": str(data.get("version", "") or ""), - "ilink_appid": str(data.get("ilink_appid", "") or ""), - } - except Exception: - return {"version": "", "ilink_appid": ""} +WEIXIN_CHANNEL_VERSION = "2.1.1" +ILINK_APP_ID = "bot" def _build_client_version(version: str) -> int: @@ -84,11 +73,7 @@ def _build_client_version(version: str) -> int: patch = _as_int(2) return ((major & 0xFF) << 16) | ((minor & 0xFF) << 8) | (patch & 0xFF) - -_PKG_META = _read_reference_package_meta() -WEIXIN_CHANNEL_VERSION = _PKG_META["version"] or "unknown" -ILINK_APP_ID = _PKG_META["ilink_appid"] -ILINK_APP_CLIENT_VERSION = _build_client_version(_PKG_META["version"] or "0.0.0") +ILINK_APP_CLIENT_VERSION = _build_client_version(WEIXIN_CHANNEL_VERSION) BASE_INFO: dict[str, str] = {"channel_version": WEIXIN_CHANNEL_VERSION} # Session-expired error code diff --git a/tests/channels/test_weixin_channel.py b/tests/channels/test_weixin_channel.py index c4e5cf55..f4d57a8b 100644 --- a/tests/channels/test_weixin_channel.py +++ b/tests/channels/test_weixin_channel.py @@ -52,8 +52,7 @@ def test_make_headers_includes_route_tag_when_configured() -> None: def test_channel_version_matches_reference_plugin_version() -> None: - pkg = json.loads(Path("package/package.json").read_text()) - assert WEIXIN_CHANNEL_VERSION == pkg["version"] + assert WEIXIN_CHANNEL_VERSION == "2.1.1" def test_save_and_load_state_persists_context_tokens(tmp_path) -> None: From 0340f81cfd47a2a60e588b6fc87f2f3ad0887237 Mon Sep 17 00:00:00 2001 From: qcypggs Date: Mon, 30 Mar 2026 19:25:55 +0800 Subject: [PATCH 14/91] fix: restore Weixin typing indicator Fetch and cache typing tickets so the Weixin channel shows typing while nanobot is processing and clears it after the final reply. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- nanobot/channels/weixin.py | 100 +++++++++++++++++++++++++- tests/channels/test_weixin_channel.py | 74 +++++++++++++++++++ 2 files changed, 173 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py index f09ef95f..9e2caae3 100644 --- a/nanobot/channels/weixin.py +++ b/nanobot/channels/weixin.py @@ -13,7 +13,6 @@ import asyncio import base64 import hashlib import json -import mimetypes import os import re import time @@ -124,6 +123,8 @@ class WeixinChannel(BaseChannel): self._poll_task: asyncio.Task | None = None self._next_poll_timeout_s: int = DEFAULT_LONG_POLL_TIMEOUT_S self._session_pause_until: float = 0.0 + self._typing_tasks: dict[str, asyncio.Task] = {} + self._typing_tickets: dict[str, str] = {} # ------------------------------------------------------------------ # State persistence @@ -158,6 +159,15 @@ class WeixinChannel(BaseChannel): } else: self._context_tokens = {} + typing_tickets = data.get("typing_tickets", {}) + if isinstance(typing_tickets, dict): + self._typing_tickets = { + str(user_id): str(ticket) + for user_id, ticket in typing_tickets.items() + if str(user_id).strip() and str(ticket).strip() + } + else: + self._typing_tickets = {} base_url = data.get("base_url", "") if base_url: self.config.base_url = base_url @@ -173,6 +183,7 @@ class WeixinChannel(BaseChannel): "token": self._token, "get_updates_buf": self._get_updates_buf, "context_tokens": self._context_tokens, + "typing_tickets": self._typing_tickets, "base_url": self.config.base_url, } state_file.write_text(json.dumps(data, ensure_ascii=False)) @@ -415,6 +426,8 @@ class WeixinChannel(BaseChannel): self._running = False if self._poll_task and not self._poll_task.done(): self._poll_task.cancel() + for chat_id in list(self._typing_tasks): + await self._stop_typing(chat_id, clear_remote=False) if self._client: await self._client.aclose() self._client = None @@ -631,6 +644,8 @@ class WeixinChannel(BaseChannel): len(content), ) + await self._start_typing(from_user_id, ctx_token) + await self._handle_message( sender_id=from_user_id, chat_id=from_user_id, @@ -720,6 +735,10 @@ class WeixinChannel(BaseChannel): logger.warning("WeChat send blocked: {}", e) return + is_progress = bool((msg.metadata or {}).get("_progress", False)) + if not is_progress: + await self._stop_typing(msg.chat_id, clear_remote=True) + content = msg.content.strip() ctx_token = self._context_tokens.get(msg.chat_id, "") if not ctx_token: @@ -753,6 +772,85 @@ class WeixinChannel(BaseChannel): logger.error("Error sending WeChat message: {}", e) raise + async def _get_typing_ticket(self, user_id: str, context_token: str) -> str: + """Fetch and cache typing ticket for a user/context pair.""" + if not self._client or not self._token or not user_id or not context_token: + return "" + cached = self._typing_tickets.get(user_id, "") + if cached: + return cached + try: + data = await self._api_post( + "ilink/bot/getconfig", + { + "ilink_user_id": user_id, + "context_token": context_token, + }, + ) + except Exception as e: + logger.debug("WeChat getconfig failed for {}: {}", user_id, e) + return "" + ticket = str(data.get("typing_ticket") or "").strip() + if ticket: + self._typing_tickets[user_id] = ticket + self._save_state() + return ticket + + async def _send_typing_status(self, to_user_id: str, typing_ticket: str, status: int) -> None: + if not typing_ticket: + return + await self._api_post( + "ilink/bot/sendtyping", + { + "ilink_user_id": to_user_id, + "typing_ticket": typing_ticket, + "status": status, + }, + ) + + async def _start_typing(self, chat_id: str, context_token: str) -> None: + if not self._client or not self._token or not chat_id or not context_token: + return + await self._stop_typing(chat_id, clear_remote=False) + ticket = await self._get_typing_ticket(chat_id, context_token) + if not ticket: + return + try: + await self._send_typing_status(chat_id, ticket, 1) + except Exception as e: + logger.debug("WeChat typing indicator failed for {}: {}", chat_id, e) + return + + async def typing_loop() -> None: + try: + while self._running: + await asyncio.sleep(5) + await self._send_typing_status(chat_id, ticket, 1) + except asyncio.CancelledError: + pass + except Exception as e: + logger.debug("WeChat typing keepalive stopped for {}: {}", chat_id, e) + + self._typing_tasks[chat_id] = asyncio.create_task(typing_loop()) + + async def _stop_typing(self, chat_id: str, *, clear_remote: bool) -> None: + task = self._typing_tasks.pop(chat_id, None) + if task and not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + if not clear_remote: + return + ticket = self._typing_tickets.get(chat_id, "") + if not ticket: + return + try: + await self._send_typing_status(chat_id, ticket, 2) + except Exception as e: + logger.debug("WeChat typing clear failed for {}: {}", chat_id, e) + async def _send_text( self, to_user_id: str, diff --git a/tests/channels/test_weixin_channel.py b/tests/channels/test_weixin_channel.py index 54d9bd93..35b01db8 100644 --- a/tests/channels/test_weixin_channel.py +++ b/tests/channels/test_weixin_channel.py @@ -278,3 +278,77 @@ async def test_process_message_skips_bot_messages() -> None: ) assert bus.inbound_size == 0 + + +@pytest.mark.asyncio +async def test_process_message_fetches_typing_ticket_and_starts_typing() -> None: + channel, _bus = _make_channel() + channel._running = True + channel._client = object() + channel._token = "token" + channel._api_post = AsyncMock(return_value={"typing_ticket": "ticket-1"}) + + await channel._process_message( + { + "message_type": 1, + "message_id": "m-typing", + "from_user_id": "wx-user", + "context_token": "ctx-typing", + "item_list": [ + {"type": ITEM_TEXT, "text_item": {"text": "hello"}}, + ], + } + ) + + assert channel._typing_tickets["wx-user"] == "ticket-1" + assert "wx-user" in channel._typing_tasks + await channel._stop_typing("wx-user", clear_remote=False) + + +@pytest.mark.asyncio +async def test_send_final_message_clears_typing_indicator() -> None: + channel, _bus = _make_channel() + channel._client = object() + channel._token = "token" + channel._context_tokens["wx-user"] = "ctx-2" + channel._typing_tickets["wx-user"] = "ticket-2" + channel._send_text = AsyncMock() + channel._api_post = AsyncMock(return_value={}) + + await channel.send( + type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})() + ) + + channel._send_text.assert_awaited_once_with("wx-user", "pong", "ctx-2") + channel._api_post.assert_awaited_once() + endpoint, body = channel._api_post.await_args.args + assert endpoint == "ilink/bot/sendtyping" + assert body["status"] == 2 + assert body["typing_ticket"] == "ticket-2" + + +@pytest.mark.asyncio +async def test_send_progress_message_keeps_typing_indicator() -> None: + channel, _bus = _make_channel() + channel._client = object() + channel._token = "token" + channel._context_tokens["wx-user"] = "ctx-2" + channel._typing_tickets["wx-user"] = "ticket-2" + channel._send_text = AsyncMock() + channel._api_post = AsyncMock(return_value={}) + + await channel.send( + type( + "Msg", + (), + { + "chat_id": "wx-user", + "content": "thinking", + "media": [], + "metadata": {"_progress": True}, + }, + )() + ) + + channel._send_text.assert_awaited_once_with("wx-user", "thinking", "ctx-2") + channel._api_post.assert_not_awaited() From 55501057ac138b4ab75e36d5ef605ea4c96a5af6 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 30 Mar 2026 14:20:14 +0000 Subject: [PATCH 15/91] refactor(api): tighten fixed-session chat input contract Reject mismatched models and require a single user message so the OpenAI-compatible endpoint reflects the fixed-session nanobot runtime without extra compatibility noise. --- nanobot/api/server.py | 27 ++++++---------- tests/test_openai_api.py | 68 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 18 deletions(-) diff --git a/nanobot/api/server.py b/nanobot/api/server.py index 2a818667..34b73ad5 100644 --- a/nanobot/api/server.py +++ b/nanobot/api/server.py @@ -69,21 +69,17 @@ async def handle_chat_completions(request: web.Request) -> web.Response: return _error_json(400, "Invalid JSON body") messages = body.get("messages") - if not messages or not isinstance(messages, list): - return _error_json(400, "messages field is required and must be a non-empty array") + if not isinstance(messages, list) or len(messages) != 1: + return _error_json(400, "Only a single user message is supported") # Stream not yet supported if body.get("stream", False): return _error_json(400, "stream=true is not supported yet. Set stream=false or omit it.") - # Extract last user message — nanobot manages its own multi-turn history - user_content = None - for msg in reversed(messages): - if msg.get("role") == "user": - user_content = msg.get("content", "") - break - if user_content is None: - return _error_json(400, "messages must contain at least one user message") + message = messages[0] + if not isinstance(message, dict) or message.get("role") != "user": + return _error_json(400, "Only a single user message is supported") + user_content = message.get("content", "") if isinstance(user_content, list): # Multi-modal content array — extract text parts user_content = " ".join( @@ -92,7 +88,9 @@ async def handle_chat_completions(request: web.Request) -> web.Response: agent_loop = request.app["agent_loop"] timeout_s: float = request.app.get("request_timeout", 120.0) - model_name: str = body.get("model") or request.app.get("model_name", "nanobot") + model_name: str = request.app.get("model_name", "nanobot") + if (requested_model := body.get("model")) and requested_model != model_name: + return _error_json(400, f"Only configured model '{model_name}' is available") session_lock: asyncio.Lock = request.app["session_lock"] logger.info("API request session_key={} content={}", API_SESSION_KEY, user_content[:80]) @@ -190,10 +188,3 @@ def create_app(agent_loop, model_name: str = "nanobot", request_timeout: float = app.router.add_get("/v1/models", handle_models) app.router.add_get("/health", handle_health) return app - - -def run_server(agent_loop, host: str = "127.0.0.1", port: int = 8900, - model_name: str = "nanobot", request_timeout: float = 120.0) -> None: - """Create and run the server (blocking).""" - app = create_app(agent_loop, model_name=model_name, request_timeout=request_timeout) - web.run_app(app, host=host, port=port, print=lambda msg: logger.info(msg)) diff --git a/tests/test_openai_api.py b/tests/test_openai_api.py index dbb47f6b..d935729a 100644 --- a/tests/test_openai_api.py +++ b/tests/test_openai_api.py @@ -14,6 +14,7 @@ from nanobot.api.server import ( _chat_completion_response, _error_json, create_app, + handle_chat_completions, ) try: @@ -93,6 +94,73 @@ async def test_stream_true_returns_400(aiohttp_client, app) -> None: assert "stream" in body["error"]["message"].lower() +@pytest.mark.asyncio +async def test_model_mismatch_returns_400() -> None: + request = MagicMock() + request.json = AsyncMock( + return_value={ + "model": "other-model", + "messages": [{"role": "user", "content": "hello"}], + } + ) + request.app = { + "agent_loop": _make_mock_agent(), + "model_name": "test-model", + "request_timeout": 10.0, + "session_lock": asyncio.Lock(), + } + + resp = await handle_chat_completions(request) + assert resp.status == 400 + body = json.loads(resp.body) + assert "test-model" in body["error"]["message"] + + +@pytest.mark.asyncio +async def test_single_user_message_required() -> None: + request = MagicMock() + request.json = AsyncMock( + return_value={ + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "previous reply"}, + ], + } + ) + request.app = { + "agent_loop": _make_mock_agent(), + "model_name": "test-model", + "request_timeout": 10.0, + "session_lock": asyncio.Lock(), + } + + resp = await handle_chat_completions(request) + assert resp.status == 400 + body = json.loads(resp.body) + assert "single user message" in body["error"]["message"].lower() + + +@pytest.mark.asyncio +async def test_single_user_message_must_have_user_role() -> None: + request = MagicMock() + request.json = AsyncMock( + return_value={ + "messages": [{"role": "system", "content": "you are a bot"}], + } + ) + request.app = { + "agent_loop": _make_mock_agent(), + "model_name": "test-model", + "request_timeout": 10.0, + "session_lock": asyncio.Lock(), + } + + resp = await handle_chat_completions(request) + assert resp.status == 400 + body = json.loads(resp.body) + assert "single user message" in body["error"]["message"].lower() + + @pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") @pytest.mark.asyncio async def test_successful_request_uses_fixed_api_session(aiohttp_client, mock_agent) -> None: From d9a5080d66874affd9812fc5bcb5c07004ccd081 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 30 Mar 2026 14:43:22 +0000 Subject: [PATCH 16/91] refactor(api): tighten fixed-session API contract Require a single user message, reject mismatched models, document the OpenAI-compatible API, and exclude api/ from core agent line counts so the interface matches nanobot's minimal fixed-session runtime. --- README.md | 76 +++++++++++++++++++++++++++++++++++++++++++++ core_agent_lines.sh | 6 ++-- 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 828b5647..01bc11c2 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ - [Configuration](#️-configuration) - [Multiple Instances](#-multiple-instances) - [CLI Reference](#-cli-reference) +- [OpenAI-Compatible API](#-openai-compatible-api) - [Docker](#-docker) - [Linux Service](#-linux-service) - [Project Structure](#-project-structure) @@ -1541,6 +1542,7 @@ nanobot gateway --config ~/.nanobot-telegram/config.json --workspace /tmp/nanobo | `nanobot agent` | Interactive chat mode | | `nanobot agent --no-markdown` | Show plain-text replies | | `nanobot agent --logs` | Show runtime logs during chat | +| `nanobot serve` | Start the OpenAI-compatible API | | `nanobot gateway` | Start the gateway | | `nanobot status` | Show status | | `nanobot provider login openai-codex` | OAuth login for providers | @@ -1569,6 +1571,80 @@ The agent can also manage this file itself — ask it to "add a periodic task" a +## 🔌 OpenAI-Compatible API + +nanobot can expose a minimal OpenAI-compatible endpoint for local integrations: + +```bash +pip install "nanobot-ai[api]" +nanobot serve +``` + +By default, the API binds to `127.0.0.1:8900`. + +### Behavior + +- Fixed session: all requests share the same nanobot session (`api:default`) +- Single-message input: each request must contain exactly one `user` message +- Fixed model: omit `model`, or pass the same model shown by `/v1/models` +- No streaming: `stream=true` is not supported + +### Endpoints + +- `GET /health` +- `GET /v1/models` +- `POST /v1/chat/completions` + +### curl + +```bash +curl http://127.0.0.1:8900/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "messages": [ + { + "role": "user", + "content": "hi" + } + ] + }' +``` + +### Python (`requests`) + +```python +import requests + +resp = requests.post( + "http://127.0.0.1:8900/v1/chat/completions", + json={ + "messages": [ + {"role": "user", "content": "hi"} + ] + }, + timeout=120, +) +resp.raise_for_status() +print(resp.json()["choices"][0]["message"]["content"]) +``` + +### Python (`openai`) + +```python +from openai import OpenAI + +client = OpenAI( + base_url="http://127.0.0.1:8900/v1", + api_key="dummy", +) + +resp = client.chat.completions.create( + model="MiniMax-M2.7", + messages=[{"role": "user", "content": "hi"}], +) +print(resp.choices[0].message.content) +``` + ## 🐳 Docker > [!TIP] diff --git a/core_agent_lines.sh b/core_agent_lines.sh index d35207cb..90f39aac 100755 --- a/core_agent_lines.sh +++ b/core_agent_lines.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Count core agent lines (excluding channels/, cli/, providers/ adapters) +# Count core agent lines (excluding channels/, cli/, api/, providers/ adapters) cd "$(dirname "$0")" || exit 1 echo "nanobot core agent line count" @@ -15,7 +15,7 @@ root=$(cat nanobot/__init__.py nanobot/__main__.py | wc -l) printf " %-16s %5s lines\n" "(root)" "$root" echo "" -total=$(find nanobot -name "*.py" ! -path "*/channels/*" ! -path "*/cli/*" ! -path "*/command/*" ! -path "*/providers/*" ! -path "*/skills/*" | xargs cat | wc -l) +total=$(find nanobot -name "*.py" ! -path "*/channels/*" ! -path "*/cli/*" ! -path "*/api/*" ! -path "*/command/*" ! -path "*/providers/*" ! -path "*/skills/*" | xargs cat | wc -l) echo " Core total: $total lines" echo "" -echo " (excludes: channels/, cli/, command/, providers/, skills/)" +echo " (excludes: channels/, cli/, api/, command/, providers/, skills/)" From 5e99b81c6e55a8ea9b99edb0ea5804d9eb731eab Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 30 Mar 2026 15:05:06 +0000 Subject: [PATCH 17/91] refactor(api): reduce compatibility and test noise Make the fixed-session API surface explicit, document its usage, exclude api/ from core agent line counts, and remove implicit aiohttp pytest fixture dependencies from API tests. --- tests/test_openai_api.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/test_openai_api.py b/tests/test_openai_api.py index d935729a..3d29d476 100644 --- a/tests/test_openai_api.py +++ b/tests/test_openai_api.py @@ -7,6 +7,7 @@ import json from unittest.mock import AsyncMock, MagicMock import pytest +import pytest_asyncio from nanobot.api.server import ( API_CHAT_ID, @@ -18,7 +19,7 @@ from nanobot.api.server import ( ) try: - import aiohttp # noqa: F401 + from aiohttp.test_utils import TestClient, TestServer HAS_AIOHTTP = True except ImportError: @@ -45,6 +46,23 @@ def app(mock_agent): return create_app(mock_agent, model_name="test-model", request_timeout=10.0) +@pytest_asyncio.fixture +async def aiohttp_client(): + clients: list[TestClient] = [] + + async def _make_client(app): + client = TestClient(TestServer(app)) + await client.start_server() + clients.append(client) + return client + + try: + yield _make_client + finally: + for client in clients: + await client.close() + + def test_error_json() -> None: resp = _error_json(400, "bad request") assert resp.status == 400 From f08de72f18c0889592458c95c547fdf03cb2e78a Mon Sep 17 00:00:00 2001 From: sontianye Date: Sun, 29 Mar 2026 22:56:02 +0800 Subject: [PATCH 18/91] feat(agent): add CompositeHook for composable lifecycle hooks Introduce a CompositeHook that fans out lifecycle callbacks to an ordered list of AgentHook instances with per-hook error isolation. Extract the nested _LoopHook and _SubagentHook to module scope as public LoopHook / SubagentHook so downstream users can subclass or compose them. Add `hooks` parameter to AgentLoop.__init__ for registering custom hooks at construction time. Closes #2603 --- nanobot/agent/__init__.py | 17 +- nanobot/agent/hook.py | 59 ++++++ nanobot/agent/loop.py | 124 +++++++---- nanobot/agent/subagent.py | 30 ++- tests/agent/test_hook_composite.py | 330 +++++++++++++++++++++++++++++ 5 files changed, 508 insertions(+), 52 deletions(-) create mode 100644 tests/agent/test_hook_composite.py diff --git a/nanobot/agent/__init__.py b/nanobot/agent/__init__.py index f9ba8b87..d3805805 100644 --- a/nanobot/agent/__init__.py +++ b/nanobot/agent/__init__.py @@ -1,8 +1,21 @@ """Agent core module.""" from nanobot.agent.context import ContextBuilder -from nanobot.agent.loop import AgentLoop +from nanobot.agent.hook import AgentHook, AgentHookContext, CompositeHook +from nanobot.agent.loop import AgentLoop, LoopHook from nanobot.agent.memory import MemoryStore from nanobot.agent.skills import SkillsLoader +from nanobot.agent.subagent import SubagentHook, SubagentManager -__all__ = ["AgentLoop", "ContextBuilder", "MemoryStore", "SkillsLoader"] +__all__ = [ + "AgentHook", + "AgentHookContext", + "AgentLoop", + "CompositeHook", + "ContextBuilder", + "LoopHook", + "MemoryStore", + "SkillsLoader", + "SubagentHook", + "SubagentManager", +] diff --git a/nanobot/agent/hook.py b/nanobot/agent/hook.py index 368c46aa..97ec7a07 100644 --- a/nanobot/agent/hook.py +++ b/nanobot/agent/hook.py @@ -5,6 +5,8 @@ from __future__ import annotations from dataclasses import dataclass, field from typing import Any +from loguru import logger + from nanobot.providers.base import LLMResponse, ToolCallRequest @@ -47,3 +49,60 @@ class AgentHook: def finalize_content(self, context: AgentHookContext, content: str | None) -> str | None: return content + + +class CompositeHook(AgentHook): + """Fan-out hook that delegates to an ordered list of hooks. + + Error isolation: async methods catch and log per-hook exceptions + so a faulty custom hook cannot crash the agent loop. + ``finalize_content`` is a pipeline (no isolation — bugs should surface). + """ + + __slots__ = ("_hooks",) + + def __init__(self, hooks: list[AgentHook]) -> None: + self._hooks = list(hooks) + + def wants_streaming(self) -> bool: + return any(h.wants_streaming() for h in self._hooks) + + async def before_iteration(self, context: AgentHookContext) -> None: + for h in self._hooks: + try: + await h.before_iteration(context) + except Exception: + logger.exception("AgentHook.before_iteration error in {}", type(h).__name__) + + async def on_stream(self, context: AgentHookContext, delta: str) -> None: + for h in self._hooks: + try: + await h.on_stream(context, delta) + except Exception: + logger.exception("AgentHook.on_stream error in {}", type(h).__name__) + + async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None: + for h in self._hooks: + try: + await h.on_stream_end(context, resuming=resuming) + except Exception: + logger.exception("AgentHook.on_stream_end error in {}", type(h).__name__) + + async def before_execute_tools(self, context: AgentHookContext) -> None: + for h in self._hooks: + try: + await h.before_execute_tools(context) + except Exception: + logger.exception("AgentHook.before_execute_tools error in {}", type(h).__name__) + + async def after_iteration(self, context: AgentHookContext) -> None: + for h in self._hooks: + try: + await h.after_iteration(context) + except Exception: + logger.exception("AgentHook.after_iteration error in {}", type(h).__name__) + + def finalize_content(self, context: AgentHookContext, content: str | None) -> str | None: + for h in self._hooks: + content = h.finalize_content(context, content) + return content diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 63ee92ca..0e58fa55 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -14,7 +14,7 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable from loguru import logger from nanobot.agent.context import ContextBuilder -from nanobot.agent.hook import AgentHook, AgentHookContext +from nanobot.agent.hook import AgentHook, AgentHookContext, CompositeHook from nanobot.agent.memory import MemoryConsolidator from nanobot.agent.runner import AgentRunSpec, AgentRunner from nanobot.agent.subagent import SubagentManager @@ -37,6 +37,71 @@ if TYPE_CHECKING: from nanobot.cron.service import CronService +class LoopHook(AgentHook): + """Core lifecycle hook for the main agent loop. + + Handles streaming delta relay, progress reporting, tool-call logging, + and think-tag stripping. Public so downstream users can subclass or + compose it via :class:`CompositeHook`. + """ + + def __init__( + self, + agent_loop: AgentLoop, + on_progress: Callable[..., Awaitable[None]] | None = None, + on_stream: Callable[[str], Awaitable[None]] | None = None, + on_stream_end: Callable[..., Awaitable[None]] | None = None, + *, + channel: str = "cli", + chat_id: str = "direct", + message_id: str | None = None, + ) -> None: + self._loop = agent_loop + self._on_progress = on_progress + self._on_stream = on_stream + self._on_stream_end = on_stream_end + self._channel = channel + self._chat_id = chat_id + self._message_id = message_id + self._stream_buf = "" + + def wants_streaming(self) -> bool: + return self._on_stream is not None + + async def on_stream(self, context: AgentHookContext, delta: str) -> None: + from nanobot.utils.helpers import strip_think + + prev_clean = strip_think(self._stream_buf) + self._stream_buf += delta + new_clean = strip_think(self._stream_buf) + incremental = new_clean[len(prev_clean):] + if incremental and self._on_stream: + await self._on_stream(incremental) + + async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None: + if self._on_stream_end: + await self._on_stream_end(resuming=resuming) + self._stream_buf = "" + + async def before_execute_tools(self, context: AgentHookContext) -> None: + if self._on_progress: + if not self._on_stream: + thought = self._loop._strip_think( + context.response.content if context.response else None + ) + if thought: + await self._on_progress(thought) + tool_hint = self._loop._strip_think(self._loop._tool_hint(context.tool_calls)) + await self._on_progress(tool_hint, tool_hint=True) + for tc in context.tool_calls: + args_str = json.dumps(tc.arguments, ensure_ascii=False) + logger.info("Tool call: {}({})", tc.name, args_str[:200]) + self._loop._set_tool_context(self._channel, self._chat_id, self._message_id) + + def finalize_content(self, context: AgentHookContext, content: str | None) -> str | None: + return self._loop._strip_think(content) + + class AgentLoop: """ The agent loop is the core processing engine. @@ -68,6 +133,7 @@ class AgentLoop: mcp_servers: dict | None = None, channels_config: ChannelsConfig | None = None, timezone: str | None = None, + hooks: list[AgentHook] | None = None, ): from nanobot.config.schema import ExecToolConfig, WebSearchConfig @@ -85,6 +151,7 @@ class AgentLoop: self.restrict_to_workspace = restrict_to_workspace self._start_time = time.time() self._last_usage: dict[str, int] = {} + self._extra_hooks: list[AgentHook] = hooks or [] self.context = ContextBuilder(workspace, timezone=timezone) self.sessions = session_manager or SessionManager(workspace) @@ -217,52 +284,27 @@ class AgentLoop: ``resuming=True`` means tool calls follow (spinner should restart); ``resuming=False`` means this is the final response. """ - loop_self = self - - class _LoopHook(AgentHook): - def __init__(self) -> None: - self._stream_buf = "" - - def wants_streaming(self) -> bool: - return on_stream is not None - - async def on_stream(self, context: AgentHookContext, delta: str) -> None: - from nanobot.utils.helpers import strip_think - - prev_clean = strip_think(self._stream_buf) - self._stream_buf += delta - new_clean = strip_think(self._stream_buf) - incremental = new_clean[len(prev_clean):] - if incremental and on_stream: - await on_stream(incremental) - - async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None: - if on_stream_end: - await on_stream_end(resuming=resuming) - self._stream_buf = "" - - async def before_execute_tools(self, context: AgentHookContext) -> None: - if on_progress: - if not on_stream: - thought = loop_self._strip_think(context.response.content if context.response else None) - if thought: - await on_progress(thought) - tool_hint = loop_self._strip_think(loop_self._tool_hint(context.tool_calls)) - await on_progress(tool_hint, tool_hint=True) - for tc in context.tool_calls: - args_str = json.dumps(tc.arguments, ensure_ascii=False) - logger.info("Tool call: {}({})", tc.name, args_str[:200]) - loop_self._set_tool_context(channel, chat_id, message_id) - - def finalize_content(self, context: AgentHookContext, content: str | None) -> str | None: - return loop_self._strip_think(content) + loop_hook = LoopHook( + self, + on_progress=on_progress, + on_stream=on_stream, + on_stream_end=on_stream_end, + channel=channel, + chat_id=chat_id, + message_id=message_id, + ) + hook: AgentHook = ( + CompositeHook([loop_hook, *self._extra_hooks]) + if self._extra_hooks + else loop_hook + ) result = await self.runner.run(AgentRunSpec( initial_messages=initial_messages, tools=self.tools, model=self.model, max_iterations=self.max_iterations, - hook=_LoopHook(), + hook=hook, error_message="Sorry, I encountered an error calling the AI model.", concurrent_tools=True, )) diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 5266fc8b..691f5382 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -21,6 +21,24 @@ from nanobot.config.schema import ExecToolConfig from nanobot.providers.base import LLMProvider +class SubagentHook(AgentHook): + """Logging-only hook for subagent execution. + + Public so downstream users can subclass or compose via :class:`CompositeHook`. + """ + + def __init__(self, task_id: str) -> None: + self._task_id = task_id + + async def before_execute_tools(self, context: AgentHookContext) -> None: + for tool_call in context.tool_calls: + args_str = json.dumps(tool_call.arguments, ensure_ascii=False) + logger.debug( + "Subagent [{}] executing: {} with arguments: {}", + self._task_id, tool_call.name, args_str, + ) + + class SubagentManager: """Manages background subagent execution.""" @@ -108,25 +126,19 @@ class SubagentManager: )) tools.register(WebSearchTool(config=self.web_search_config, proxy=self.web_proxy)) tools.register(WebFetchTool(proxy=self.web_proxy)) - + system_prompt = self._build_subagent_prompt() messages: list[dict[str, Any]] = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": task}, ] - class _SubagentHook(AgentHook): - async def before_execute_tools(self, context: AgentHookContext) -> None: - for tool_call in context.tool_calls: - 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 self.runner.run(AgentRunSpec( initial_messages=messages, tools=tools, model=self.model, max_iterations=15, - hook=_SubagentHook(), + hook=SubagentHook(task_id), max_iterations_message="Task completed but no final response was generated.", error_message=None, fail_on_tool_error=True, @@ -213,7 +225,7 @@ Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not men lines.append("Failure:") lines.append(f"- {result.error}") return "\n".join(lines) or (result.error or "Error: subagent execution failed.") - + def _build_subagent_prompt(self) -> str: """Build a focused system prompt for the subagent.""" from nanobot.agent.context import ContextBuilder diff --git a/tests/agent/test_hook_composite.py b/tests/agent/test_hook_composite.py new file mode 100644 index 00000000..8a43a424 --- /dev/null +++ b/tests/agent/test_hook_composite.py @@ -0,0 +1,330 @@ +"""Tests for CompositeHook fan-out, error isolation, and integration.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from nanobot.agent.hook import AgentHook, AgentHookContext, CompositeHook + + +def _ctx() -> AgentHookContext: + return AgentHookContext(iteration=0, messages=[]) + + +# --------------------------------------------------------------------------- +# Fan-out: every hook is called in order +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_composite_fans_out_before_iteration(): + calls: list[str] = [] + + class H(AgentHook): + async def before_iteration(self, context: AgentHookContext) -> None: + calls.append(f"A:{context.iteration}") + + class H2(AgentHook): + async def before_iteration(self, context: AgentHookContext) -> None: + calls.append(f"B:{context.iteration}") + + hook = CompositeHook([H(), H2()]) + ctx = _ctx() + await hook.before_iteration(ctx) + assert calls == ["A:0", "B:0"] + + +@pytest.mark.asyncio +async def test_composite_fans_out_all_async_methods(): + """Verify all async methods fan out to every hook.""" + events: list[str] = [] + + class RecordingHook(AgentHook): + async def before_iteration(self, context: AgentHookContext) -> None: + events.append("before_iteration") + + async def on_stream(self, context: AgentHookContext, delta: str) -> None: + events.append(f"on_stream:{delta}") + + async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None: + events.append(f"on_stream_end:{resuming}") + + async def before_execute_tools(self, context: AgentHookContext) -> None: + events.append("before_execute_tools") + + async def after_iteration(self, context: AgentHookContext) -> None: + events.append("after_iteration") + + hook = CompositeHook([RecordingHook(), RecordingHook()]) + ctx = _ctx() + + await hook.before_iteration(ctx) + await hook.on_stream(ctx, "hi") + await hook.on_stream_end(ctx, resuming=True) + await hook.before_execute_tools(ctx) + await hook.after_iteration(ctx) + + assert events == [ + "before_iteration", "before_iteration", + "on_stream:hi", "on_stream:hi", + "on_stream_end:True", "on_stream_end:True", + "before_execute_tools", "before_execute_tools", + "after_iteration", "after_iteration", + ] + + +# --------------------------------------------------------------------------- +# Error isolation: one hook raises, others still run +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_composite_error_isolation_before_iteration(): + calls: list[str] = [] + + class Bad(AgentHook): + async def before_iteration(self, context: AgentHookContext) -> None: + raise RuntimeError("boom") + + class Good(AgentHook): + async def before_iteration(self, context: AgentHookContext) -> None: + calls.append("good") + + hook = CompositeHook([Bad(), Good()]) + await hook.before_iteration(_ctx()) + assert calls == ["good"] + + +@pytest.mark.asyncio +async def test_composite_error_isolation_on_stream(): + calls: list[str] = [] + + class Bad(AgentHook): + async def on_stream(self, context: AgentHookContext, delta: str) -> None: + raise RuntimeError("stream-boom") + + class Good(AgentHook): + async def on_stream(self, context: AgentHookContext, delta: str) -> None: + calls.append(delta) + + hook = CompositeHook([Bad(), Good()]) + await hook.on_stream(_ctx(), "delta") + assert calls == ["delta"] + + +@pytest.mark.asyncio +async def test_composite_error_isolation_all_async(): + """Error isolation for on_stream_end, before_execute_tools, after_iteration.""" + calls: list[str] = [] + + class Bad(AgentHook): + async def on_stream_end(self, context, *, resuming): + raise RuntimeError("err") + async def before_execute_tools(self, context): + raise RuntimeError("err") + async def after_iteration(self, context): + raise RuntimeError("err") + + class Good(AgentHook): + async def on_stream_end(self, context, *, resuming): + calls.append("on_stream_end") + async def before_execute_tools(self, context): + calls.append("before_execute_tools") + async def after_iteration(self, context): + calls.append("after_iteration") + + hook = CompositeHook([Bad(), Good()]) + ctx = _ctx() + await hook.on_stream_end(ctx, resuming=False) + await hook.before_execute_tools(ctx) + await hook.after_iteration(ctx) + assert calls == ["on_stream_end", "before_execute_tools", "after_iteration"] + + +# --------------------------------------------------------------------------- +# finalize_content: pipeline semantics (no error isolation) +# --------------------------------------------------------------------------- + + +def test_composite_finalize_content_pipeline(): + class Upper(AgentHook): + def finalize_content(self, context, content): + return content.upper() if content else content + + class Suffix(AgentHook): + def finalize_content(self, context, content): + return (content + "!") if content else content + + hook = CompositeHook([Upper(), Suffix()]) + result = hook.finalize_content(_ctx(), "hello") + assert result == "HELLO!" + + +def test_composite_finalize_content_none_passthrough(): + hook = CompositeHook([AgentHook()]) + assert hook.finalize_content(_ctx(), None) is None + + +def test_composite_finalize_content_ordering(): + """First hook transforms first, result feeds second hook.""" + steps: list[str] = [] + + class H1(AgentHook): + def finalize_content(self, context, content): + steps.append(f"H1:{content}") + return content.upper() + + class H2(AgentHook): + def finalize_content(self, context, content): + steps.append(f"H2:{content}") + return content + "!" + + hook = CompositeHook([H1(), H2()]) + result = hook.finalize_content(_ctx(), "hi") + assert result == "HI!" + assert steps == ["H1:hi", "H2:HI"] + + +# --------------------------------------------------------------------------- +# wants_streaming: any-semantics +# --------------------------------------------------------------------------- + + +def test_composite_wants_streaming_any_true(): + class No(AgentHook): + def wants_streaming(self): + return False + + class Yes(AgentHook): + def wants_streaming(self): + return True + + hook = CompositeHook([No(), Yes(), No()]) + assert hook.wants_streaming() is True + + +def test_composite_wants_streaming_all_false(): + hook = CompositeHook([AgentHook(), AgentHook()]) + assert hook.wants_streaming() is False + + +def test_composite_wants_streaming_empty(): + hook = CompositeHook([]) + assert hook.wants_streaming() is False + + +# --------------------------------------------------------------------------- +# Empty hooks list: behaves like no-op AgentHook +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_composite_empty_hooks_no_ops(): + hook = CompositeHook([]) + ctx = _ctx() + await hook.before_iteration(ctx) + await hook.on_stream(ctx, "delta") + await hook.on_stream_end(ctx, resuming=False) + await hook.before_execute_tools(ctx) + await hook.after_iteration(ctx) + assert hook.finalize_content(ctx, "test") == "test" + + +# --------------------------------------------------------------------------- +# Integration: AgentLoop with extra hooks +# --------------------------------------------------------------------------- + + +def _make_loop(tmp_path, hooks=None): + from nanobot.agent.loop import AgentLoop + from nanobot.bus.queue import MessageBus + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + provider.generation.max_tokens = 4096 + + with patch("nanobot.agent.loop.ContextBuilder"), \ + patch("nanobot.agent.loop.SessionManager"), \ + patch("nanobot.agent.loop.SubagentManager") as mock_sub_mgr, \ + patch("nanobot.agent.loop.MemoryConsolidator"): + mock_sub_mgr.return_value.cancel_by_session = AsyncMock(return_value=0) + loop = AgentLoop( + bus=bus, provider=provider, workspace=tmp_path, hooks=hooks, + ) + return loop + + +@pytest.mark.asyncio +async def test_agent_loop_extra_hook_receives_calls(tmp_path): + """Extra hook passed to AgentLoop is called alongside core LoopHook.""" + from nanobot.providers.base import LLMResponse + + events: list[str] = [] + + class TrackingHook(AgentHook): + async def before_iteration(self, context): + events.append(f"before_iter:{context.iteration}") + + async def after_iteration(self, context): + events.append(f"after_iter:{context.iteration}") + + loop = _make_loop(tmp_path, hooks=[TrackingHook()]) + loop.provider.chat_with_retry = AsyncMock( + return_value=LLMResponse(content="done", tool_calls=[], usage={}) + ) + loop.tools.get_definitions = MagicMock(return_value=[]) + + content, tools_used, messages = await loop._run_agent_loop( + [{"role": "user", "content": "hi"}] + ) + + assert content == "done" + assert "before_iter:0" in events + assert "after_iter:0" in events + + +@pytest.mark.asyncio +async def test_agent_loop_extra_hook_error_isolation(tmp_path): + """A faulty extra hook does not crash the agent loop.""" + from nanobot.providers.base import LLMResponse + + class BadHook(AgentHook): + async def before_iteration(self, context): + raise RuntimeError("I am broken") + + loop = _make_loop(tmp_path, hooks=[BadHook()]) + loop.provider.chat_with_retry = AsyncMock( + return_value=LLMResponse(content="still works", tool_calls=[], usage={}) + ) + loop.tools.get_definitions = MagicMock(return_value=[]) + + content, _, _ = await loop._run_agent_loop( + [{"role": "user", "content": "hi"}] + ) + + assert content == "still works" + + +@pytest.mark.asyncio +async def test_agent_loop_no_hooks_backward_compat(tmp_path): + """Without hooks param, behavior is identical to before.""" + from nanobot.providers.base import LLMResponse, ToolCallRequest + + loop = _make_loop(tmp_path) + loop.provider.chat_with_retry = AsyncMock(return_value=LLMResponse( + content="working", + tool_calls=[ToolCallRequest(id="c1", name="list_dir", arguments={"path": "."})], + )) + loop.tools.get_definitions = MagicMock(return_value=[]) + loop.tools.execute = AsyncMock(return_value="ok") + loop.max_iterations = 2 + + content, tools_used, _ = await loop._run_agent_loop([]) + assert content == ( + "I reached the maximum number of tool call iterations (2) " + "without completing the task. You can try breaking the task into smaller steps." + ) + assert tools_used == ["list_dir", "list_dir"] From 758c4e74c9d3f6e494d497a050f12b5d5bdad2f8 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 30 Mar 2026 17:57:49 +0000 Subject: [PATCH 19/91] fix(agent): preserve LoopHook error semantics when extra hooks are present --- nanobot/agent/loop.py | 43 +++++++++++++++++++++++++++++- tests/agent/test_hook_composite.py | 21 +++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 0e58fa55..c4525765 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -102,6 +102,47 @@ class LoopHook(AgentHook): return self._loop._strip_think(content) +class _LoopHookChain(AgentHook): + """Run the core loop hook first, then best-effort extra hooks. + + This preserves the historical failure behavior of ``LoopHook`` while still + letting user-supplied hooks opt into ``CompositeHook`` isolation. + """ + + __slots__ = ("_primary", "_extras") + + def __init__(self, primary: AgentHook, extra_hooks: list[AgentHook]) -> None: + self._primary = primary + self._extras = CompositeHook(extra_hooks) + + def wants_streaming(self) -> bool: + return self._primary.wants_streaming() or self._extras.wants_streaming() + + async def before_iteration(self, context: AgentHookContext) -> None: + await self._primary.before_iteration(context) + await self._extras.before_iteration(context) + + async def on_stream(self, context: AgentHookContext, delta: str) -> None: + await self._primary.on_stream(context, delta) + await self._extras.on_stream(context, delta) + + async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None: + await self._primary.on_stream_end(context, resuming=resuming) + await self._extras.on_stream_end(context, resuming=resuming) + + async def before_execute_tools(self, context: AgentHookContext) -> None: + await self._primary.before_execute_tools(context) + await self._extras.before_execute_tools(context) + + async def after_iteration(self, context: AgentHookContext) -> None: + await self._primary.after_iteration(context) + await self._extras.after_iteration(context) + + def finalize_content(self, context: AgentHookContext, content: str | None) -> str | None: + content = self._primary.finalize_content(context, content) + return self._extras.finalize_content(context, content) + + class AgentLoop: """ The agent loop is the core processing engine. @@ -294,7 +335,7 @@ class AgentLoop: message_id=message_id, ) hook: AgentHook = ( - CompositeHook([loop_hook, *self._extra_hooks]) + _LoopHookChain(loop_hook, self._extra_hooks) if self._extra_hooks else loop_hook ) diff --git a/tests/agent/test_hook_composite.py b/tests/agent/test_hook_composite.py index 8a43a424..203c892f 100644 --- a/tests/agent/test_hook_composite.py +++ b/tests/agent/test_hook_composite.py @@ -308,6 +308,27 @@ async def test_agent_loop_extra_hook_error_isolation(tmp_path): assert content == "still works" +@pytest.mark.asyncio +async def test_agent_loop_extra_hooks_do_not_swallow_loop_hook_errors(tmp_path): + """Extra hooks must not change the core LoopHook failure behavior.""" + from nanobot.providers.base import LLMResponse, ToolCallRequest + + loop = _make_loop(tmp_path, hooks=[AgentHook()]) + loop.provider.chat_with_retry = AsyncMock(return_value=LLMResponse( + content="working", + tool_calls=[ToolCallRequest(id="c1", name="list_dir", arguments={"path": "."})], + usage={}, + )) + loop.tools.get_definitions = MagicMock(return_value=[]) + loop.tools.execute = AsyncMock(return_value="ok") + + async def bad_progress(*args, **kwargs): + raise RuntimeError("progress failed") + + with pytest.raises(RuntimeError, match="progress failed"): + await loop._run_agent_loop([], on_progress=bad_progress) + + @pytest.mark.asyncio async def test_agent_loop_no_hooks_backward_compat(tmp_path): """Without hooks param, behavior is identical to before.""" From 842b8b255dc472e55e206b3c2c04af5d29ffe8c3 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 30 Mar 2026 18:14:11 +0000 Subject: [PATCH 20/91] fix(agent): preserve core hook failure semantics --- nanobot/agent/__init__.py | 6 ++---- nanobot/agent/loop.py | 9 ++++----- nanobot/agent/subagent.py | 9 +++------ 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/nanobot/agent/__init__.py b/nanobot/agent/__init__.py index d3805805..7d3ab2af 100644 --- a/nanobot/agent/__init__.py +++ b/nanobot/agent/__init__.py @@ -2,10 +2,10 @@ from nanobot.agent.context import ContextBuilder from nanobot.agent.hook import AgentHook, AgentHookContext, CompositeHook -from nanobot.agent.loop import AgentLoop, LoopHook +from nanobot.agent.loop import AgentLoop from nanobot.agent.memory import MemoryStore from nanobot.agent.skills import SkillsLoader -from nanobot.agent.subagent import SubagentHook, SubagentManager +from nanobot.agent.subagent import SubagentManager __all__ = [ "AgentHook", @@ -13,9 +13,7 @@ __all__ = [ "AgentLoop", "CompositeHook", "ContextBuilder", - "LoopHook", "MemoryStore", "SkillsLoader", - "SubagentHook", "SubagentManager", ] diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index c4525765..97d352cb 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -37,12 +37,11 @@ if TYPE_CHECKING: from nanobot.cron.service import CronService -class LoopHook(AgentHook): +class _LoopHook(AgentHook): """Core lifecycle hook for the main agent loop. Handles streaming delta relay, progress reporting, tool-call logging, - and think-tag stripping. Public so downstream users can subclass or - compose it via :class:`CompositeHook`. + and think-tag stripping for the built-in agent path. """ def __init__( @@ -105,7 +104,7 @@ class LoopHook(AgentHook): class _LoopHookChain(AgentHook): """Run the core loop hook first, then best-effort extra hooks. - This preserves the historical failure behavior of ``LoopHook`` while still + This preserves the historical failure behavior of ``_LoopHook`` while still letting user-supplied hooks opt into ``CompositeHook`` isolation. """ @@ -325,7 +324,7 @@ class AgentLoop: ``resuming=True`` means tool calls follow (spinner should restart); ``resuming=False`` means this is the final response. """ - loop_hook = LoopHook( + loop_hook = _LoopHook( self, on_progress=on_progress, on_stream=on_stream, diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 691f5382..c1aaa2d0 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -21,11 +21,8 @@ from nanobot.config.schema import ExecToolConfig from nanobot.providers.base import LLMProvider -class SubagentHook(AgentHook): - """Logging-only hook for subagent execution. - - Public so downstream users can subclass or compose via :class:`CompositeHook`. - """ +class _SubagentHook(AgentHook): + """Logging-only hook for subagent execution.""" def __init__(self, task_id: str) -> None: self._task_id = task_id @@ -138,7 +135,7 @@ class SubagentManager: tools=tools, model=self.model, max_iterations=15, - hook=SubagentHook(task_id), + hook=_SubagentHook(task_id), max_iterations_message="Task completed but no final response was generated.", error_message=None, fail_on_tool_error=True, From 7fad14802e77983176a6c60649fcf3ff63ecc1ab Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 30 Mar 2026 18:46:11 +0000 Subject: [PATCH 21/91] feat: add Python SDK facade and per-session isolation --- README.md | 53 ++++++++--- core_agent_lines.sh | 7 +- docs/PYTHON_SDK.md | 136 ++++++++++++++++++++++++++++ nanobot/__init__.py | 4 + nanobot/api/server.py | 21 +++-- nanobot/nanobot.py | 170 +++++++++++++++++++++++++++++++++++ tests/test_nanobot_facade.py | 147 ++++++++++++++++++++++++++++++ 7 files changed, 515 insertions(+), 23 deletions(-) create mode 100644 docs/PYTHON_SDK.md create mode 100644 nanobot/nanobot.py create mode 100644 tests/test_nanobot_facade.py diff --git a/README.md b/README.md index 01bc11c2..8a8c864d 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ - [Configuration](#️-configuration) - [Multiple Instances](#-multiple-instances) - [CLI Reference](#-cli-reference) +- [Python SDK](#-python-sdk) - [OpenAI-Compatible API](#-openai-compatible-api) - [Docker](#-docker) - [Linux Service](#-linux-service) @@ -1571,6 +1572,40 @@ The agent can also manage this file itself — ask it to "add a periodic task" a +## 🐍 Python SDK + +Use nanobot as a library — no CLI, no gateway, just Python: + +```python +from nanobot import Nanobot + +bot = Nanobot.from_config() +result = await bot.run("Summarize the README") +print(result.content) +``` + +Each call carries a `session_key` for conversation isolation — different keys get independent history: + +```python +await bot.run("hi", session_key="user-alice") +await bot.run("hi", session_key="task-42") +``` + +Add lifecycle hooks to observe or customize the agent: + +```python +from nanobot.agent import AgentHook, AgentHookContext + +class AuditHook(AgentHook): + async def before_execute_tools(self, ctx: AgentHookContext) -> None: + for tc in ctx.tool_calls: + print(f"[tool] {tc.name}") + +result = await bot.run("Hello", hooks=[AuditHook()]) +``` + +See [docs/PYTHON_SDK.md](docs/PYTHON_SDK.md) for the full SDK reference. + ## 🔌 OpenAI-Compatible API nanobot can expose a minimal OpenAI-compatible endpoint for local integrations: @@ -1580,11 +1615,11 @@ pip install "nanobot-ai[api]" nanobot serve ``` -By default, the API binds to `127.0.0.1:8900`. +By default, the API binds to `127.0.0.1:8900`. You can change this in `config.json`. ### Behavior -- Fixed session: all requests share the same nanobot session (`api:default`) +- Session isolation: pass `"session_id"` in the request body to isolate conversations; omit for a shared default session (`api:default`) - Single-message input: each request must contain exactly one `user` message - Fixed model: omit `model`, or pass the same model shown by `/v1/models` - No streaming: `stream=true` is not supported @@ -1601,12 +1636,8 @@ By default, the API binds to `127.0.0.1:8900`. curl http://127.0.0.1:8900/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ - "messages": [ - { - "role": "user", - "content": "hi" - } - ] + "messages": [{"role": "user", "content": "hi"}], + "session_id": "my-session" }' ``` @@ -1618,9 +1649,8 @@ import requests resp = requests.post( "http://127.0.0.1:8900/v1/chat/completions", json={ - "messages": [ - {"role": "user", "content": "hi"} - ] + "messages": [{"role": "user", "content": "hi"}], + "session_id": "my-session", # optional: isolate conversation }, timeout=120, ) @@ -1641,6 +1671,7 @@ client = OpenAI( resp = client.chat.completions.create( model="MiniMax-M2.7", messages=[{"role": "user", "content": "hi"}], + extra_body={"session_id": "my-session"}, # optional: isolate conversation ) print(resp.choices[0].message.content) ``` diff --git a/core_agent_lines.sh b/core_agent_lines.sh index 90f39aac..0891347d 100755 --- a/core_agent_lines.sh +++ b/core_agent_lines.sh @@ -1,5 +1,6 @@ #!/bin/bash -# Count core agent lines (excluding channels/, cli/, api/, providers/ adapters) +# Count core agent lines (excluding channels/, cli/, api/, providers/ adapters, +# and the high-level Python SDK facade) cd "$(dirname "$0")" || exit 1 echo "nanobot core agent line count" @@ -15,7 +16,7 @@ root=$(cat nanobot/__init__.py nanobot/__main__.py | wc -l) printf " %-16s %5s lines\n" "(root)" "$root" echo "" -total=$(find nanobot -name "*.py" ! -path "*/channels/*" ! -path "*/cli/*" ! -path "*/api/*" ! -path "*/command/*" ! -path "*/providers/*" ! -path "*/skills/*" | xargs cat | wc -l) +total=$(find nanobot -name "*.py" ! -path "*/channels/*" ! -path "*/cli/*" ! -path "*/api/*" ! -path "*/command/*" ! -path "*/providers/*" ! -path "*/skills/*" ! -path "nanobot/nanobot.py" | xargs cat | wc -l) echo " Core total: $total lines" echo "" -echo " (excludes: channels/, cli/, api/, command/, providers/, skills/)" +echo " (excludes: channels/, cli/, api/, command/, providers/, skills/, nanobot.py)" diff --git a/docs/PYTHON_SDK.md b/docs/PYTHON_SDK.md new file mode 100644 index 00000000..357722e5 --- /dev/null +++ b/docs/PYTHON_SDK.md @@ -0,0 +1,136 @@ +# Python SDK + +Use nanobot programmatically — load config, run the agent, get results. + +## Quick Start + +```python +import asyncio +from nanobot import Nanobot + +async def main(): + bot = Nanobot.from_config() + result = await bot.run("What time is it in Tokyo?") + print(result.content) + +asyncio.run(main()) +``` + +## API + +### `Nanobot.from_config(config_path?, *, workspace?)` + +Create a `Nanobot` from a config file. + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `config_path` | `str \| Path \| None` | `None` | Path to `config.json`. Defaults to `~/.nanobot/config.json`. | +| `workspace` | `str \| Path \| None` | `None` | Override workspace directory from config. | + +Raises `FileNotFoundError` if an explicit path doesn't exist. + +### `await bot.run(message, *, session_key?, hooks?)` + +Run the agent once. Returns a `RunResult`. + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `message` | `str` | *(required)* | The user message to process. | +| `session_key` | `str` | `"sdk:default"` | Session identifier for conversation isolation. Different keys get independent history. | +| `hooks` | `list[AgentHook] \| None` | `None` | Lifecycle hooks for this run only. | + +```python +# Isolated sessions — each user gets independent conversation history +await bot.run("hi", session_key="user-alice") +await bot.run("hi", session_key="user-bob") +``` + +### `RunResult` + +| Field | Type | Description | +|-------|------|-------------| +| `content` | `str` | The agent's final text response. | +| `tools_used` | `list[str]` | Tool names invoked during the run. | +| `messages` | `list[dict]` | Raw message history (for debugging). | + +## Hooks + +Hooks let you observe or modify the agent loop without touching internals. + +Subclass `AgentHook` and override any method: + +| Method | When | +|--------|------| +| `before_iteration(ctx)` | Before each LLM call | +| `on_stream(ctx, delta)` | On each streamed token | +| `on_stream_end(ctx)` | When streaming finishes | +| `before_execute_tools(ctx)` | Before tool execution (inspect `ctx.tool_calls`) | +| `after_iteration(ctx, response)` | After each LLM response | +| `finalize_content(ctx, content)` | Transform final output text | + +### Example: Audit Hook + +```python +from nanobot.agent import AgentHook, AgentHookContext + +class AuditHook(AgentHook): + def __init__(self): + self.calls = [] + + async def before_execute_tools(self, ctx: AgentHookContext) -> None: + for tc in ctx.tool_calls: + self.calls.append(tc.name) + print(f"[audit] {tc.name}({tc.arguments})") + +hook = AuditHook() +result = await bot.run("List files in /tmp", hooks=[hook]) +print(f"Tools used: {hook.calls}") +``` + +### Composing Hooks + +Pass multiple hooks — they run in order, errors in one don't block others: + +```python +result = await bot.run("hi", hooks=[AuditHook(), MetricsHook()]) +``` + +Under the hood this uses `CompositeHook` for fan-out with error isolation. + +### `finalize_content` Pipeline + +Unlike the async methods (fan-out), `finalize_content` is a pipeline — each hook's output feeds the next: + +```python +class Censor(AgentHook): + def finalize_content(self, ctx, content): + return content.replace("secret", "***") if content else content +``` + +## Full Example + +```python +import asyncio +from nanobot import Nanobot +from nanobot.agent import AgentHook, AgentHookContext + +class TimingHook(AgentHook): + async def before_iteration(self, ctx: AgentHookContext) -> None: + import time + ctx.metadata["_t0"] = time.time() + + async def after_iteration(self, ctx, response) -> None: + import time + elapsed = time.time() - ctx.metadata.get("_t0", 0) + print(f"[timing] iteration took {elapsed:.2f}s") + +async def main(): + bot = Nanobot.from_config(workspace="/my/project") + result = await bot.run( + "Explain the main function", + hooks=[TimingHook()], + ) + print(result.content) + +asyncio.run(main()) +``` diff --git a/nanobot/__init__.py b/nanobot/__init__.py index 07efd09c..11833c69 100644 --- a/nanobot/__init__.py +++ b/nanobot/__init__.py @@ -4,3 +4,7 @@ nanobot - A lightweight AI agent framework __version__ = "0.1.4.post6" __logo__ = "🐈" + +from nanobot.nanobot import Nanobot, RunResult + +__all__ = ["Nanobot", "RunResult"] diff --git a/nanobot/api/server.py b/nanobot/api/server.py index 34b73ad5..9494b6e3 100644 --- a/nanobot/api/server.py +++ b/nanobot/api/server.py @@ -91,9 +91,12 @@ async def handle_chat_completions(request: web.Request) -> web.Response: model_name: str = request.app.get("model_name", "nanobot") if (requested_model := body.get("model")) and requested_model != model_name: return _error_json(400, f"Only configured model '{model_name}' is available") - session_lock: asyncio.Lock = request.app["session_lock"] - logger.info("API request session_key={} content={}", API_SESSION_KEY, user_content[:80]) + session_key = f"api:{body['session_id']}" if body.get("session_id") else API_SESSION_KEY + session_locks: dict[str, asyncio.Lock] = request.app["session_locks"] + session_lock = session_locks.setdefault(session_key, asyncio.Lock()) + + logger.info("API request session_key={} content={}", session_key, user_content[:80]) _FALLBACK = "I've completed processing but have no response to give." @@ -103,7 +106,7 @@ async def handle_chat_completions(request: web.Request) -> web.Response: response = await asyncio.wait_for( agent_loop.process_direct( content=user_content, - session_key=API_SESSION_KEY, + session_key=session_key, channel="api", chat_id=API_CHAT_ID, ), @@ -114,12 +117,12 @@ async def handle_chat_completions(request: web.Request) -> web.Response: if not response_text or not response_text.strip(): logger.warning( "Empty response for session {}, retrying", - API_SESSION_KEY, + session_key, ) retry_response = await asyncio.wait_for( agent_loop.process_direct( content=user_content, - session_key=API_SESSION_KEY, + session_key=session_key, channel="api", chat_id=API_CHAT_ID, ), @@ -129,17 +132,17 @@ async def handle_chat_completions(request: web.Request) -> web.Response: if not response_text or not response_text.strip(): logger.warning( "Empty response after retry for session {}, using fallback", - API_SESSION_KEY, + session_key, ) response_text = _FALLBACK except asyncio.TimeoutError: return _error_json(504, f"Request timed out after {timeout_s}s") except Exception: - logger.exception("Error processing request for session {}", API_SESSION_KEY) + logger.exception("Error processing request for session {}", session_key) return _error_json(500, "Internal server error", err_type="server_error") except Exception: - logger.exception("Unexpected API lock error for session {}", API_SESSION_KEY) + logger.exception("Unexpected API lock error for session {}", session_key) return _error_json(500, "Internal server error", err_type="server_error") return web.json_response(_chat_completion_response(response_text, model_name)) @@ -182,7 +185,7 @@ def create_app(agent_loop, model_name: str = "nanobot", request_timeout: float = app["agent_loop"] = agent_loop app["model_name"] = model_name app["request_timeout"] = request_timeout - app["session_lock"] = asyncio.Lock() + app["session_locks"] = {} # per-user locks, keyed by session_key app.router.add_post("/v1/chat/completions", handle_chat_completions) app.router.add_get("/v1/models", handle_models) diff --git a/nanobot/nanobot.py b/nanobot/nanobot.py new file mode 100644 index 00000000..13768845 --- /dev/null +++ b/nanobot/nanobot.py @@ -0,0 +1,170 @@ +"""High-level programmatic interface to nanobot.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from nanobot.agent.hook import AgentHook +from nanobot.agent.loop import AgentLoop +from nanobot.bus.queue import MessageBus + + +@dataclass(slots=True) +class RunResult: + """Result of a single agent run.""" + + content: str + tools_used: list[str] + messages: list[dict[str, Any]] + + +class Nanobot: + """Programmatic facade for running the nanobot agent. + + Usage:: + + bot = Nanobot.from_config() + result = await bot.run("Summarize this repo", hooks=[MyHook()]) + print(result.content) + """ + + def __init__(self, loop: AgentLoop) -> None: + self._loop = loop + + @classmethod + def from_config( + cls, + config_path: str | Path | None = None, + *, + workspace: str | Path | None = None, + ) -> Nanobot: + """Create a Nanobot instance from a config file. + + Args: + config_path: Path to ``config.json``. Defaults to + ``~/.nanobot/config.json``. + workspace: Override the workspace directory from config. + """ + from nanobot.config.loader import load_config + from nanobot.config.schema import Config + + resolved: Path | None = None + if config_path is not None: + resolved = Path(config_path).expanduser().resolve() + if not resolved.exists(): + raise FileNotFoundError(f"Config not found: {resolved}") + + config: Config = load_config(resolved) + if workspace is not None: + config.agents.defaults.workspace = str( + Path(workspace).expanduser().resolve() + ) + + provider = _make_provider(config) + bus = MessageBus() + defaults = config.agents.defaults + + loop = AgentLoop( + bus=bus, + provider=provider, + workspace=config.workspace_path, + model=defaults.model, + max_iterations=defaults.max_tool_iterations, + context_window_tokens=defaults.context_window_tokens, + web_search_config=config.tools.web.search, + web_proxy=config.tools.web.proxy or None, + exec_config=config.tools.exec, + restrict_to_workspace=config.tools.restrict_to_workspace, + mcp_servers=config.tools.mcp_servers, + timezone=defaults.timezone, + ) + return cls(loop) + + async def run( + self, + message: str, + *, + session_key: str = "sdk:default", + hooks: list[AgentHook] | None = None, + ) -> RunResult: + """Run the agent once and return the result. + + Args: + message: The user message to process. + session_key: Session identifier for conversation isolation. + Different keys get independent history. + hooks: Optional lifecycle hooks for this run. + """ + prev = self._loop._extra_hooks + if hooks is not None: + self._loop._extra_hooks = list(hooks) + try: + response = await self._loop.process_direct( + message, session_key=session_key, + ) + finally: + self._loop._extra_hooks = prev + + content = (response.content if response else None) or "" + return RunResult(content=content, tools_used=[], messages=[]) + + +def _make_provider(config: Any) -> Any: + """Create the LLM provider from config (extracted from CLI).""" + from nanobot.providers.base import GenerationSettings + from nanobot.providers.registry import find_by_name + + model = config.agents.defaults.model + provider_name = config.get_provider_name(model) + p = config.get_provider(model) + spec = find_by_name(provider_name) if provider_name else None + backend = spec.backend if spec else "openai_compat" + + if backend == "azure_openai": + if not p or not p.api_key or not p.api_base: + raise ValueError("Azure OpenAI requires api_key and api_base in config.") + elif backend == "openai_compat" and not model.startswith("bedrock/"): + needs_key = not (p and p.api_key) + exempt = spec and (spec.is_oauth or spec.is_local or spec.is_direct) + if needs_key and not exempt: + raise ValueError(f"No API key configured for provider '{provider_name}'.") + + if backend == "openai_codex": + from nanobot.providers.openai_codex_provider import OpenAICodexProvider + + provider = OpenAICodexProvider(default_model=model) + elif backend == "azure_openai": + from nanobot.providers.azure_openai_provider import AzureOpenAIProvider + + provider = AzureOpenAIProvider( + api_key=p.api_key, api_base=p.api_base, default_model=model + ) + elif backend == "anthropic": + from nanobot.providers.anthropic_provider import AnthropicProvider + + provider = AnthropicProvider( + api_key=p.api_key if p else None, + api_base=config.get_api_base(model), + default_model=model, + extra_headers=p.extra_headers if p else None, + ) + else: + from nanobot.providers.openai_compat_provider import OpenAICompatProvider + + provider = OpenAICompatProvider( + api_key=p.api_key if p else None, + api_base=config.get_api_base(model), + default_model=model, + extra_headers=p.extra_headers if p else None, + spec=spec, + ) + + defaults = config.agents.defaults + provider.generation = GenerationSettings( + temperature=defaults.temperature, + max_tokens=defaults.max_tokens, + reasoning_effort=defaults.reasoning_effort, + ) + return provider diff --git a/tests/test_nanobot_facade.py b/tests/test_nanobot_facade.py new file mode 100644 index 00000000..9d0d8a17 --- /dev/null +++ b/tests/test_nanobot_facade.py @@ -0,0 +1,147 @@ +"""Tests for the Nanobot programmatic facade.""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from nanobot.nanobot import Nanobot, RunResult + + +def _write_config(tmp_path: Path, overrides: dict | None = None) -> Path: + data = { + "providers": {"openrouter": {"apiKey": "sk-test-key"}}, + "agents": {"defaults": {"model": "openai/gpt-4.1"}}, + } + if overrides: + data.update(overrides) + config_path = tmp_path / "config.json" + config_path.write_text(json.dumps(data)) + return config_path + + +def test_from_config_missing_file(): + with pytest.raises(FileNotFoundError): + Nanobot.from_config("/nonexistent/config.json") + + +def test_from_config_creates_instance(tmp_path): + config_path = _write_config(tmp_path) + bot = Nanobot.from_config(config_path, workspace=tmp_path) + assert bot._loop is not None + assert bot._loop.workspace == tmp_path + + +def test_from_config_default_path(): + from nanobot.config.schema import Config + + with patch("nanobot.config.loader.load_config") as mock_load, \ + patch("nanobot.nanobot._make_provider") as mock_prov: + mock_load.return_value = Config() + mock_prov.return_value = MagicMock() + mock_prov.return_value.get_default_model.return_value = "test" + mock_prov.return_value.generation.max_tokens = 4096 + Nanobot.from_config() + mock_load.assert_called_once_with(None) + + +@pytest.mark.asyncio +async def test_run_returns_result(tmp_path): + config_path = _write_config(tmp_path) + bot = Nanobot.from_config(config_path, workspace=tmp_path) + + from nanobot.bus.events import OutboundMessage + + mock_response = OutboundMessage( + channel="cli", chat_id="direct", content="Hello back!" + ) + bot._loop.process_direct = AsyncMock(return_value=mock_response) + + result = await bot.run("hi") + + assert isinstance(result, RunResult) + assert result.content == "Hello back!" + bot._loop.process_direct.assert_awaited_once_with("hi", session_key="sdk:default") + + +@pytest.mark.asyncio +async def test_run_with_hooks(tmp_path): + from nanobot.agent.hook import AgentHook, AgentHookContext + from nanobot.bus.events import OutboundMessage + + config_path = _write_config(tmp_path) + bot = Nanobot.from_config(config_path, workspace=tmp_path) + + class TestHook(AgentHook): + async def before_iteration(self, context: AgentHookContext) -> None: + pass + + mock_response = OutboundMessage( + channel="cli", chat_id="direct", content="done" + ) + bot._loop.process_direct = AsyncMock(return_value=mock_response) + + result = await bot.run("hi", hooks=[TestHook()]) + + assert result.content == "done" + assert bot._loop._extra_hooks == [] + + +@pytest.mark.asyncio +async def test_run_hooks_restored_on_error(tmp_path): + config_path = _write_config(tmp_path) + bot = Nanobot.from_config(config_path, workspace=tmp_path) + + from nanobot.agent.hook import AgentHook + + bot._loop.process_direct = AsyncMock(side_effect=RuntimeError("boom")) + original_hooks = bot._loop._extra_hooks + + with pytest.raises(RuntimeError): + await bot.run("hi", hooks=[AgentHook()]) + + assert bot._loop._extra_hooks is original_hooks + + +@pytest.mark.asyncio +async def test_run_none_response(tmp_path): + config_path = _write_config(tmp_path) + bot = Nanobot.from_config(config_path, workspace=tmp_path) + bot._loop.process_direct = AsyncMock(return_value=None) + + result = await bot.run("hi") + assert result.content == "" + + +def test_workspace_override(tmp_path): + config_path = _write_config(tmp_path) + custom_ws = tmp_path / "custom_workspace" + custom_ws.mkdir() + + bot = Nanobot.from_config(config_path, workspace=custom_ws) + assert bot._loop.workspace == custom_ws + + +@pytest.mark.asyncio +async def test_run_custom_session_key(tmp_path): + from nanobot.bus.events import OutboundMessage + + config_path = _write_config(tmp_path) + bot = Nanobot.from_config(config_path, workspace=tmp_path) + + mock_response = OutboundMessage( + channel="cli", chat_id="direct", content="ok" + ) + bot._loop.process_direct = AsyncMock(return_value=mock_response) + + await bot.run("hi", session_key="user-alice") + bot._loop.process_direct.assert_awaited_once_with("hi", session_key="user-alice") + + +def test_import_from_top_level(): + from nanobot import Nanobot as N, RunResult as R + assert N is Nanobot + assert R is RunResult From 8682b017e25af0eaf658d8b862222efb13a9b1e0 Mon Sep 17 00:00:00 2001 From: 04cb <0x04cb@gmail.com> Date: Tue, 31 Mar 2026 08:53:35 +0800 Subject: [PATCH 22/91] fix(tools): add Accept header for MCP SSE connections (#2651) --- nanobot/agent/tools/mcp.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index c1c3e79a..51533333 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -170,7 +170,11 @@ async def connect_mcp_servers( timeout: httpx.Timeout | None = None, auth: httpx.Auth | None = None, ) -> httpx.AsyncClient: - merged_headers = {**(cfg.headers or {}), **(headers or {})} + merged_headers = { + "Accept": "application/json, text/event-stream", + **(cfg.headers or {}), + **(headers or {}), + } return httpx.AsyncClient( headers=merged_headers or None, follow_redirects=True, From 3f21e83af8056dcdb682cc7eee0a10b667460da1 Mon Sep 17 00:00:00 2001 From: 04cb <0x04cb@gmail.com> Date: Tue, 31 Mar 2026 08:53:39 +0800 Subject: [PATCH 23/91] fix(tools): clarify cron message param as agent instruction (#2566) --- nanobot/agent/tools/cron.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index 9989af55..00f726c0 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -74,7 +74,7 @@ class CronTool(Tool): "enum": ["add", "list", "remove"], "description": "Action to perform", }, - "message": {"type": "string", "description": "Reminder message (for add)"}, + "message": {"type": "string", "description": "Instruction for the agent to execute when the job triggers (e.g., 'Send a reminder to WeChat: xxx' or 'Check system status and report')"}, "every_seconds": { "type": "integer", "description": "Interval in seconds (for recurring tasks)", From 929ee094995f716bfa9cff6d69cdd5b1bd6dd7d9 Mon Sep 17 00:00:00 2001 From: 04cb <0x04cb@gmail.com> Date: Tue, 31 Mar 2026 08:53:44 +0800 Subject: [PATCH 24/91] fix(utils): ensure reasoning_content present with thinking_blocks (#2579) --- nanobot/utils/helpers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index a10a4f18..a7c2c257 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -124,8 +124,8 @@ def build_assistant_message( msg: dict[str, Any] = {"role": "assistant", "content": content} if tool_calls: msg["tool_calls"] = tool_calls - if reasoning_content is not None: - msg["reasoning_content"] = reasoning_content + if reasoning_content is not None or thinking_blocks: + msg["reasoning_content"] = reasoning_content if reasoning_content is not None else "" if thinking_blocks: msg["thinking_blocks"] = thinking_blocks return msg From c3c1424db35e1158377c8d2beb7168d3dd104573 Mon Sep 17 00:00:00 2001 From: "zhangxiaoyu.york" Date: Tue, 31 Mar 2026 00:09:01 +0800 Subject: [PATCH 25/91] fix:register exec when enable exec_config --- nanobot/agent/subagent.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index c1aaa2d0..9d936f03 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -115,12 +115,13 @@ class SubagentManager: tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) tools.register(EditFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir)) - tools.register(ExecTool( - working_dir=str(self.workspace), - timeout=self.exec_config.timeout, - restrict_to_workspace=self.restrict_to_workspace, - path_append=self.exec_config.path_append, - )) + if self.exec_config.enable: + tools.register(ExecTool( + working_dir=str(self.workspace), + timeout=self.exec_config.timeout, + restrict_to_workspace=self.restrict_to_workspace, + path_append=self.exec_config.path_append, + )) tools.register(WebSearchTool(config=self.web_search_config, proxy=self.web_proxy)) tools.register(WebFetchTool(proxy=self.web_proxy)) From 351e3720b6c65ab12b4eba4fd2eb859c0096042a Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Tue, 31 Mar 2026 04:11:54 +0000 Subject: [PATCH 26/91] test(agent): cover disabled subagent exec tool Add a regression test for the maintainer fix so subagents cannot register ExecTool when exec support is disabled. Made-with: Cursor --- tests/agent/test_task_cancel.py | 34 +++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/agent/test_task_cancel.py b/tests/agent/test_task_cancel.py index 8894cd97..4902a4c8 100644 --- a/tests/agent/test_task_cancel.py +++ b/tests/agent/test_task_cancel.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -222,6 +223,39 @@ class TestSubagentCancellation: assert assistant_messages[0]["reasoning_content"] == "hidden reasoning" assert assistant_messages[0]["thinking_blocks"] == [{"type": "thinking", "thinking": "step"}] + @pytest.mark.asyncio + async def test_subagent_exec_tool_not_registered_when_disabled(self, tmp_path): + from nanobot.agent.subagent import SubagentManager + from nanobot.bus.queue import MessageBus + from nanobot.config.schema import ExecToolConfig + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + mgr = SubagentManager( + provider=provider, + workspace=tmp_path, + bus=bus, + exec_config=ExecToolConfig(enable=False), + ) + mgr._announce_result = AsyncMock() + + async def fake_run(spec): + assert spec.tools.get("exec") is None + return SimpleNamespace( + stop_reason="done", + final_content="done", + error=None, + tool_events=[], + ) + + mgr.runner.run = AsyncMock(side_effect=fake_run) + + await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"}) + + mgr.runner.run.assert_awaited_once() + mgr._announce_result.assert_awaited_once() + @pytest.mark.asyncio async def test_subagent_announces_error_when_tool_execution_fails(self, monkeypatch, tmp_path): from nanobot.agent.subagent import SubagentManager From d0c68157b11a470144b96e5a0afdb5ce0a846ebd Mon Sep 17 00:00:00 2001 From: xcosmosbox <2162381070@qq.com> Date: Tue, 31 Mar 2026 12:55:29 +0800 Subject: [PATCH 27/91] fix(WeiXin): fix full_url download error --- nanobot/channels/weixin.py | 142 ++++++++++++-------------- tests/channels/test_weixin_channel.py | 63 ++++++++++++ 2 files changed, 126 insertions(+), 79 deletions(-) diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py index 7f6c6aba..c6c1603a 100644 --- a/nanobot/channels/weixin.py +++ b/nanobot/channels/weixin.py @@ -197,8 +197,7 @@ class WeixinChannel(BaseChannel): if base_url: self.config.base_url = base_url return bool(self._token) - except Exception as e: - logger.warning("Failed to load WeChat state: {}", e) + except Exception: return False def _save_state(self) -> None: @@ -211,8 +210,8 @@ class WeixinChannel(BaseChannel): "base_url": self.config.base_url, } state_file.write_text(json.dumps(data, ensure_ascii=False)) - except Exception as e: - logger.warning("Failed to save WeChat state: {}", e) + except Exception: + pass # ------------------------------------------------------------------ # HTTP helpers (matches api.ts buildHeaders / apiFetch) @@ -243,6 +242,15 @@ class WeixinChannel(BaseChannel): headers["SKRouteTag"] = str(self.config.route_tag).strip() return headers + @staticmethod + def _is_retryable_media_download_error(err: Exception) -> bool: + if isinstance(err, httpx.TimeoutException | httpx.TransportError): + return True + if isinstance(err, httpx.HTTPStatusError): + status_code = err.response.status_code if err.response is not None else 0 + return status_code >= 500 + return False + async def _api_get( self, endpoint: str, @@ -315,13 +323,11 @@ class WeixinChannel(BaseChannel): async def _qr_login(self) -> bool: """Perform QR code login flow. Returns True on success.""" try: - logger.info("Starting WeChat QR code login...") refresh_count = 0 qrcode_id, scan_url = await self._fetch_qr_code() self._print_qr_code(scan_url) current_poll_base_url = self.config.base_url - logger.info("Waiting for QR code scan...") while self._running: try: status_data = await self._api_get_with_base( @@ -332,13 +338,11 @@ class WeixinChannel(BaseChannel): ) except Exception as e: if self._is_retryable_qr_poll_error(e): - logger.warning("QR polling temporary error, will retry: {}", e) await asyncio.sleep(1) continue raise if not isinstance(status_data, dict): - logger.warning("QR polling got non-object response, continue waiting") await asyncio.sleep(1) continue @@ -362,8 +366,6 @@ class WeixinChannel(BaseChannel): else: logger.error("Login confirmed but no bot_token in response") return False - elif status == "scaned": - logger.info("QR code scanned, waiting for confirmation...") elif status == "scaned_but_redirect": redirect_host = str(status_data.get("redirect_host", "") or "").strip() if redirect_host: @@ -372,15 +374,7 @@ class WeixinChannel(BaseChannel): else: redirected_base = f"https://{redirect_host}" if redirected_base != current_poll_base_url: - logger.info( - "QR status redirect: switching polling host to {}", - redirected_base, - ) current_poll_base_url = redirected_base - else: - logger.warning( - "QR status returned scaned_but_redirect but redirect_host is missing", - ) elif status == "expired": refresh_count += 1 if refresh_count > MAX_QR_REFRESH_COUNT: @@ -390,14 +384,8 @@ class WeixinChannel(BaseChannel): MAX_QR_REFRESH_COUNT, ) return False - logger.warning( - "QR code expired, refreshing... ({}/{})", - refresh_count, - MAX_QR_REFRESH_COUNT, - ) qrcode_id, scan_url = await self._fetch_qr_code() self._print_qr_code(scan_url) - logger.info("New QR code generated, waiting for scan...") continue # status == "wait" — keep polling @@ -428,7 +416,6 @@ class WeixinChannel(BaseChannel): qr.make(fit=True) qr.print_ascii(invert=True) except ImportError: - logger.info("QR code URL (install 'qrcode' for terminal display): {}", url) print(f"\nLogin URL: {url}\n") # ------------------------------------------------------------------ @@ -490,12 +477,6 @@ class WeixinChannel(BaseChannel): if not self._running: break consecutive_failures += 1 - logger.error( - "WeChat poll error ({}/{}): {}", - consecutive_failures, - MAX_CONSECUTIVE_FAILURES, - e, - ) if consecutive_failures >= MAX_CONSECUTIVE_FAILURES: consecutive_failures = 0 await asyncio.sleep(BACKOFF_DELAY_S) @@ -510,8 +491,6 @@ class WeixinChannel(BaseChannel): await self._client.aclose() self._client = None self._save_state() - logger.info("WeChat channel stopped") - # ------------------------------------------------------------------ # Polling (matches monitor.ts monitorWeixinProvider) # ------------------------------------------------------------------ @@ -537,10 +516,6 @@ class WeixinChannel(BaseChannel): async def _poll_once(self) -> None: remaining = self._session_pause_remaining_s() if remaining > 0: - logger.warning( - "WeChat session paused, waiting {} min before next poll.", - max((remaining + 59) // 60, 1), - ) await asyncio.sleep(remaining) return @@ -590,8 +565,8 @@ class WeixinChannel(BaseChannel): for msg in msgs: try: await self._process_message(msg) - except Exception as e: - logger.error("Error processing WeChat message: {}", e) + except Exception: + pass # ------------------------------------------------------------------ # Inbound message processing (matches inbound.ts + process-message.ts) @@ -770,13 +745,6 @@ class WeixinChannel(BaseChannel): if not content: return - logger.info( - "WeChat inbound: from={} items={} bodyLen={}", - from_user_id, - ",".join(str(i.get("type", 0)) for i in item_list), - len(content), - ) - await self._handle_message( sender_id=from_user_id, chat_id=from_user_id, @@ -821,27 +789,47 @@ class WeixinChannel(BaseChannel): # Reference protocol behavior: VOICE/FILE/VIDEO require aes_key; # only IMAGE may be downloaded as plain bytes when key is missing. if media_type != "image" and not aes_key_b64: - logger.debug("Missing AES key for {} item, skip media download", media_type) return None - # Prefer server-provided full_url, fallback to encrypted_query_param URL construction. - if full_url: - cdn_url = full_url - else: - cdn_url = ( + assert self._client is not None + fallback_url = "" + if encrypt_query_param: + fallback_url = ( f"{self.config.cdn_base_url}/download" f"?encrypted_query_param={quote(encrypt_query_param)}" ) - assert self._client is not None - resp = await self._client.get(cdn_url) - resp.raise_for_status() - data = resp.content + download_candidates: list[tuple[str, str]] = [] + if full_url: + download_candidates.append(("full_url", full_url)) + if fallback_url and (not full_url or fallback_url != full_url): + download_candidates.append(("encrypt_query_param", fallback_url)) + + data = b"" + for idx, (download_source, cdn_url) in enumerate(download_candidates): + try: + resp = await self._client.get(cdn_url) + resp.raise_for_status() + data = resp.content + break + except Exception as e: + has_more_candidates = idx + 1 < len(download_candidates) + should_fallback = ( + download_source == "full_url" + and has_more_candidates + and self._is_retryable_media_download_error(e) + ) + if should_fallback: + logger.warning( + "WeChat media download failed via full_url, falling back to encrypt_query_param: type={} err={}", + media_type, + e, + ) + continue + raise if aes_key_b64 and data: data = _decrypt_aes_ecb(data, aes_key_b64) - elif not aes_key_b64: - logger.debug("No AES key for {} item, using raw bytes", media_type) if not data: return None @@ -856,7 +844,6 @@ class WeixinChannel(BaseChannel): safe_name = os.path.basename(filename) file_path = media_dir / safe_name file_path.write_bytes(data) - logger.debug("Downloaded WeChat {} to {}", media_type, file_path) return str(file_path) except Exception as e: @@ -918,14 +905,17 @@ class WeixinChannel(BaseChannel): await self._api_post("ilink/bot/sendtyping", body) async def _typing_keepalive_loop(self, user_id: str, typing_ticket: str, stop_event: asyncio.Event) -> None: - while not stop_event.is_set(): - await asyncio.sleep(TYPING_KEEPALIVE_INTERVAL_S) - if stop_event.is_set(): - break - try: - await self._send_typing(user_id, typing_ticket, TYPING_STATUS_TYPING) - except Exception as e: - logger.debug("WeChat sendtyping(keepalive) failed for {}: {}", user_id, e) + try: + while not stop_event.is_set(): + await asyncio.sleep(TYPING_KEEPALIVE_INTERVAL_S) + if stop_event.is_set(): + break + try: + await self._send_typing(user_id, typing_ticket, TYPING_STATUS_TYPING) + except Exception: + pass + finally: + pass async def send(self, msg: OutboundMessage) -> None: if not self._client or not self._token: @@ -933,8 +923,7 @@ class WeixinChannel(BaseChannel): return try: self._assert_session_active() - except RuntimeError as e: - logger.warning("WeChat send blocked: {}", e) + except RuntimeError: return content = msg.content.strip() @@ -949,15 +938,14 @@ class WeixinChannel(BaseChannel): typing_ticket = "" try: typing_ticket = await self._get_typing_ticket(msg.chat_id, ctx_token) - except Exception as e: - logger.warning("WeChat getconfig failed for {}: {}", msg.chat_id, e) + except Exception: typing_ticket = "" if typing_ticket: try: await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_TYPING) - except Exception as e: - logger.debug("WeChat sendtyping(start) failed for {}: {}", msg.chat_id, e) + except Exception: + pass typing_keepalive_stop = asyncio.Event() typing_keepalive_task: asyncio.Task | None = None @@ -1001,8 +989,8 @@ class WeixinChannel(BaseChannel): if typing_ticket: try: await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_CANCEL) - except Exception as e: - logger.debug("WeChat sendtyping(cancel) failed for {}: {}", msg.chat_id, e) + except Exception: + pass async def _send_text( self, @@ -1108,7 +1096,6 @@ class WeixinChannel(BaseChannel): assert self._client is not None upload_resp = await self._api_post("ilink/bot/getuploadurl", upload_body) - logger.debug("WeChat getuploadurl response: {}", upload_resp) upload_full_url = str(upload_resp.get("upload_full_url", "") or "").strip() upload_param = str(upload_resp.get("upload_param", "") or "") @@ -1130,7 +1117,6 @@ class WeixinChannel(BaseChannel): f"?encrypted_query_param={quote(upload_param)}" f"&filekey={quote(file_key)}" ) - logger.debug("WeChat CDN POST url={} ciphertextSize={}", cdn_upload_url[:80], len(encrypted_data)) cdn_resp = await self._client.post( cdn_upload_url, @@ -1146,7 +1132,6 @@ class WeixinChannel(BaseChannel): "CDN upload response missing x-encrypted-param header; " f"status={cdn_resp.status_code} headers={dict(cdn_resp.headers)}" ) - logger.debug("WeChat CDN upload success for {}, got download_param", p.name) # Step 3: Send message with the media item # aes_key for CDNMedia is the hex key encoded as base64 @@ -1195,7 +1180,6 @@ class WeixinChannel(BaseChannel): raise RuntimeError( f"WeChat send media error (code {errcode}): {data.get('errmsg', '')}" ) - logger.info("WeChat media sent: {} (type={})", p.name, item_key) # --------------------------------------------------------------------------- diff --git a/tests/channels/test_weixin_channel.py b/tests/channels/test_weixin_channel.py index f4d57a8b..515eaa28 100644 --- a/tests/channels/test_weixin_channel.py +++ b/tests/channels/test_weixin_channel.py @@ -766,6 +766,21 @@ class _DummyDownloadResponse: return None +class _DummyErrorDownloadResponse(_DummyDownloadResponse): + def __init__(self, url: str, status_code: int) -> None: + super().__init__(content=b"", status_code=status_code) + self._url = url + + def raise_for_status(self) -> None: + request = httpx.Request("GET", self._url) + response = httpx.Response(self.status_code, request=request) + raise httpx.HTTPStatusError( + f"download failed with status {self.status_code}", + request=request, + response=response, + ) + + @pytest.mark.asyncio async def test_download_media_item_uses_full_url_when_present(tmp_path) -> None: channel, _bus = _make_channel() @@ -789,6 +804,37 @@ async def test_download_media_item_uses_full_url_when_present(tmp_path) -> None: channel._client.get.assert_awaited_once_with(full_url) +@pytest.mark.asyncio +async def test_download_media_item_falls_back_when_full_url_returns_retryable_error(tmp_path) -> None: + channel, _bus = _make_channel() + weixin_mod.get_media_dir = lambda _name: tmp_path + + full_url = "https://cdn.example.test/download/full?taskid=123" + channel._client = SimpleNamespace( + get=AsyncMock( + side_effect=[ + _DummyErrorDownloadResponse(full_url, 500), + _DummyDownloadResponse(content=b"fallback-bytes"), + ] + ) + ) + + item = { + "media": { + "full_url": full_url, + "encrypt_query_param": "enc-fallback", + }, + } + saved_path = await channel._download_media_item(item, "image") + + assert saved_path is not None + assert Path(saved_path).read_bytes() == b"fallback-bytes" + assert channel._client.get.await_count == 2 + assert channel._client.get.await_args_list[0].args[0] == full_url + fallback_url = channel._client.get.await_args_list[1].args[0] + assert fallback_url.startswith(f"{channel.config.cdn_base_url}/download?encrypted_query_param=enc-fallback") + + @pytest.mark.asyncio async def test_download_media_item_falls_back_to_encrypt_query_param(tmp_path) -> None: channel, _bus = _make_channel() @@ -807,6 +853,23 @@ async def test_download_media_item_falls_back_to_encrypt_query_param(tmp_path) - assert called_url.startswith(f"{channel.config.cdn_base_url}/download?encrypted_query_param=enc-fallback") +@pytest.mark.asyncio +async def test_download_media_item_does_not_retry_when_full_url_fails_without_fallback(tmp_path) -> None: + channel, _bus = _make_channel() + weixin_mod.get_media_dir = lambda _name: tmp_path + + full_url = "https://cdn.example.test/download/full" + channel._client = SimpleNamespace( + get=AsyncMock(return_value=_DummyErrorDownloadResponse(full_url, 500)) + ) + + item = {"media": {"full_url": full_url}} + saved_path = await channel._download_media_item(item, "image") + + assert saved_path is None + channel._client.get.assert_awaited_once_with(full_url) + + @pytest.mark.asyncio async def test_download_media_item_non_image_requires_aes_key_even_with_full_url(tmp_path) -> None: channel, _bus = _make_channel() From b94d4c0509e1d273703a5fb2c05f3b6e630e5668 Mon Sep 17 00:00:00 2001 From: npodbielski Date: Fri, 27 Mar 2026 08:12:14 +0100 Subject: [PATCH 28/91] feat(matrix): streaming support (#2447) * Added streaming message support with incremental updates for Matrix channel * Improve Matrix message handling and add tests * Adjust Matrix streaming edit interval to 2 seconds --------- Co-authored-by: natan --- nanobot/channels/matrix.py | 107 +++++++++++- tests/channels/test_matrix_channel.py | 225 +++++++++++++++++++++++++- 2 files changed, 323 insertions(+), 9 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 98926735..dcece104 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -3,6 +3,8 @@ import asyncio import logging import mimetypes +import time +from dataclasses import dataclass from pathlib import Path from typing import Any, Literal, TypeAlias @@ -28,8 +30,8 @@ try: RoomSendError, RoomTypingError, SyncError, - UploadError, - ) + UploadError, RoomSendResponse, +) from nio.crypto.attachments import decrypt_attachment from nio.exceptions import EncryptionError except ImportError as e: @@ -97,6 +99,22 @@ MATRIX_HTML_CLEANER = nh3.Cleaner( link_rel="noopener noreferrer", ) +@dataclass +class _StreamBuf: + """ + Represents a buffer for managing LLM response stream data. + + :ivar text: Stores the text content of the buffer. + :type text: str + :ivar event_id: Identifier for the associated event. None indicates no + specific event association. + :type event_id: str | None + :ivar last_edit: Timestamp of the most recent edit to the buffer. + :type last_edit: float + """ + text: str = "" + event_id: str | None = None + last_edit: float = 0.0 def _render_markdown_html(text: str) -> str | None: """Render markdown to sanitized HTML; returns None for plain text.""" @@ -114,12 +132,36 @@ def _render_markdown_html(text: str) -> str | None: return formatted -def _build_matrix_text_content(text: str) -> dict[str, object]: - """Build Matrix m.text payload with optional HTML formatted_body.""" +def _build_matrix_text_content(text: str, event_id: str | None = None) -> dict[str, object]: + """ + Constructs and returns a dictionary representing the matrix text content with optional + HTML formatting and reference to an existing event for replacement. This function is + primarily used to create content payloads compatible with the Matrix messaging protocol. + + :param text: The plain text content to include in the message. + :type text: str + :param event_id: Optional ID of the event to replace. If provided, the function will + include information indicating that the message is a replacement of the specified + event. + :type event_id: str | None + :return: A dictionary containing the matrix text content, potentially enriched with + HTML formatting and replacement metadata if applicable. + :rtype: dict[str, object] + """ content: dict[str, object] = {"msgtype": "m.text", "body": text, "m.mentions": {}} if html := _render_markdown_html(text): content["format"] = MATRIX_HTML_FORMAT content["formatted_body"] = html + if event_id: + content["m.new_content"] = { + "body": text, + "msgtype": "m.text" + } + content["m.relates_to"] = { + "rel_type": "m.replace", + "event_id": event_id + } + return content @@ -159,7 +201,8 @@ class MatrixConfig(Base): allow_from: list[str] = Field(default_factory=list) group_policy: Literal["open", "mention", "allowlist"] = "open" group_allow_from: list[str] = Field(default_factory=list) - allow_room_mentions: bool = False + allow_room_mentions: bool = False, + streaming: bool = False class MatrixChannel(BaseChannel): @@ -167,6 +210,8 @@ class MatrixChannel(BaseChannel): name = "matrix" display_name = "Matrix" + _STREAM_EDIT_INTERVAL = 2 # min seconds between edit_message_text calls + monotonic_time = time.monotonic @classmethod def default_config(cls) -> dict[str, Any]: @@ -192,6 +237,8 @@ class MatrixChannel(BaseChannel): ) self._server_upload_limit_bytes: int | None = None self._server_upload_limit_checked = False + self._stream_bufs: dict[str, _StreamBuf] = {} + async def start(self) -> None: """Start Matrix client and begin sync loop.""" @@ -297,14 +344,17 @@ class MatrixChannel(BaseChannel): room = getattr(self.client, "rooms", {}).get(room_id) return bool(getattr(room, "encrypted", False)) - async def _send_room_content(self, room_id: str, content: dict[str, Any]) -> None: + async def _send_room_content(self, room_id: str, + content: dict[str, Any]) -> None | RoomSendResponse | RoomSendError: """Send m.room.message with E2EE options.""" if not self.client: - return + return None kwargs: dict[str, Any] = {"room_id": room_id, "message_type": "m.room.message", "content": content} + if self.config.e2ee_enabled: kwargs["ignore_unverified_devices"] = True - await self.client.room_send(**kwargs) + response = await self.client.room_send(**kwargs) + return response async def _resolve_server_upload_limit_bytes(self) -> int | None: """Query homeserver upload limit once per channel lifecycle.""" @@ -414,6 +464,47 @@ class MatrixChannel(BaseChannel): if not is_progress: await self._stop_typing_keepalive(msg.chat_id, clear_typing=True) + async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None: + meta = metadata or {} + relates_to = self._build_thread_relates_to(metadata) + + if meta.get("_stream_end"): + buf = self._stream_bufs.pop(chat_id, None) + if not buf or not buf.event_id or not buf.text: + return + + await self._stop_typing_keepalive(chat_id, clear_typing=True) + + content = _build_matrix_text_content(buf.text, buf.event_id) + if relates_to: + content["m.relates_to"] = relates_to + await self._send_room_content(chat_id, content) + return + + buf = self._stream_bufs.get(chat_id) + if buf is None: + buf = _StreamBuf() + self._stream_bufs[chat_id] = buf + buf.text += delta + + if not buf.text.strip(): + return + + now = self.monotonic_time() + + if not buf.last_edit or (now - buf.last_edit) >= self._STREAM_EDIT_INTERVAL: + try: + content = _build_matrix_text_content(buf.text, buf.event_id) + response = await self._send_room_content(chat_id, content) + buf.last_edit = now + if not buf.event_id: + # we are editing the same message all the time, so only the first time the event id needs to be set + buf.event_id = response.event_id + except Exception: + await self._stop_typing_keepalive(metadata["room_id"], clear_typing=True) + pass + + def _register_event_callbacks(self) -> None: self.client.add_event_callback(self._on_message, RoomMessageText) self.client.add_event_callback(self._on_media_message, MATRIX_MEDIA_EVENT_FILTER) diff --git a/tests/channels/test_matrix_channel.py b/tests/channels/test_matrix_channel.py index dd5e97d9..3ad65e76 100644 --- a/tests/channels/test_matrix_channel.py +++ b/tests/channels/test_matrix_channel.py @@ -3,6 +3,9 @@ from pathlib import Path from types import SimpleNamespace import pytest +from nio import RoomSendResponse + +from nanobot.channels.matrix import _build_matrix_text_content # Check optional matrix dependencies before importing try: @@ -65,6 +68,7 @@ class _FakeAsyncClient: self.raise_on_send = False self.raise_on_typing = False self.raise_on_upload = False + self.room_send_response: RoomSendResponse | None = RoomSendResponse(event_id="", room_id="") def add_event_callback(self, callback, event_type) -> None: self.callbacks.append((callback, event_type)) @@ -87,7 +91,7 @@ class _FakeAsyncClient: message_type: str, content: dict[str, object], ignore_unverified_devices: object = _ROOM_SEND_UNSET, - ) -> None: + ) -> RoomSendResponse: call: dict[str, object] = { "room_id": room_id, "message_type": message_type, @@ -98,6 +102,7 @@ class _FakeAsyncClient: self.room_send_calls.append(call) if self.raise_on_send: raise RuntimeError("send failed") + return self.room_send_response async def room_typing( self, @@ -520,6 +525,7 @@ async def test_on_message_room_mention_requires_opt_in() -> None: source={"content": {"m.mentions": {"room": True}}}, ) + channel.config.allow_room_mentions = False await channel._on_message(room, room_mention_event) assert handled == [] assert client.typing_calls == [] @@ -1322,3 +1328,220 @@ async def test_send_keeps_plaintext_only_for_plain_text() -> None: "body": text, "m.mentions": {}, } + + +def test_build_matrix_text_content_basic_text() -> None: + """Test basic text content without HTML formatting.""" + result = _build_matrix_text_content("Hello, World!") + expected = { + "msgtype": "m.text", + "body": "Hello, World!", + "m.mentions": {} + } + assert expected == result + + +def test_build_matrix_text_content_with_markdown() -> None: + """Test text content with markdown that renders to HTML.""" + text = "*Hello* **World**" + result = _build_matrix_text_content(text) + assert "msgtype" in result + assert "body" in result + assert result["body"] == text + assert "format" in result + assert result["format"] == "org.matrix.custom.html" + assert "formatted_body" in result + assert isinstance(result["formatted_body"], str) + assert len(result["formatted_body"]) > 0 + + +def test_build_matrix_text_content_with_event_id() -> None: + """Test text content with event_id for message replacement.""" + event_id = "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo" + result = _build_matrix_text_content("Updated message", event_id) + assert "msgtype" in result + assert "body" in result + assert result["m.new_content"] + assert result["m.new_content"]["body"] == "Updated message" + assert result["m.relates_to"]["rel_type"] == "m.replace" + assert result["m.relates_to"]["event_id"] == event_id + + +def test_build_matrix_text_content_no_event_id() -> None: + """Test that when event_id is not provided, no extra properties are added.""" + result = _build_matrix_text_content("Regular message") + + # Basic required properties should be present + assert "msgtype" in result + assert "body" in result + assert result["body"] == "Regular message" + + # Extra properties for replacement should NOT be present + assert "m.relates_to" not in result + assert "m.new_content" not in result + assert "format" not in result + assert "formatted_body" not in result + + +def test_build_matrix_text_content_plain_text_no_html() -> None: + """Test plain text that should not include HTML formatting.""" + result = _build_matrix_text_content("Simple plain text") + assert "msgtype" in result + assert "body" in result + assert "format" not in result + assert "formatted_body" not in result + + +@pytest.mark.asyncio +async def test_send_room_content_returns_room_send_response(): + """Test that _send_room_content returns the response from client.room_send.""" + client = _FakeAsyncClient("", "", "", None) + channel = MatrixChannel(_make_config(), MessageBus()) + channel.client = client + + room_id = "!test_room:matrix.org" + content = {"msgtype": "m.text", "body": "Hello World"} + + result = await channel._send_room_content(room_id, content) + + assert result is client.room_send_response + + +@pytest.mark.asyncio +async def test_send_delta_creates_stream_buffer_and_sends_initial_message() -> None: + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + client.room_send_response.event_id = "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo" + + await channel.send_delta("!room:matrix.org", "Hello") + + assert "!room:matrix.org" in channel._stream_bufs + buf = channel._stream_bufs["!room:matrix.org"] + assert buf.text == "Hello" + assert buf.event_id == "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo" + assert len(client.room_send_calls) == 1 + assert client.room_send_calls[0]["content"]["body"] == "Hello" + + +@pytest.mark.asyncio +async def test_send_delta_appends_without_sending_before_edit_interval(monkeypatch) -> None: + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + client.room_send_response.event_id = "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo" + + now = 100.0 + monkeypatch.setattr(channel, "monotonic_time", lambda: now) + + await channel.send_delta("!room:matrix.org", "Hello") + assert len(client.room_send_calls) == 1 + + await channel.send_delta("!room:matrix.org", " world") + assert len(client.room_send_calls) == 1 + + buf = channel._stream_bufs["!room:matrix.org"] + assert buf.text == "Hello world" + assert buf.event_id == "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo" + + +@pytest.mark.asyncio +async def test_send_delta_edits_again_after_interval(monkeypatch) -> None: + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + client.room_send_response.event_id = "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo" + + times = [100.0, 102.0, 104.0, 106.0, 108.0] + times.reverse() + monkeypatch.setattr(channel, "monotonic_time", lambda: times and times.pop()) + + await channel.send_delta("!room:matrix.org", "Hello") + await channel.send_delta("!room:matrix.org", " world") + + assert len(client.room_send_calls) == 2 + first_content = client.room_send_calls[0]["content"] + second_content = client.room_send_calls[1]["content"] + + assert "body" in first_content + assert first_content["body"] == "Hello" + assert "m.relates_to" not in first_content + + assert "body" in second_content + assert "m.relates_to" in second_content + assert second_content["body"] == "Hello world" + assert second_content["m.relates_to"] == { + "rel_type": "m.replace", + "event_id": "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo", + } + + +@pytest.mark.asyncio +async def test_send_delta_stream_end_replaces_existing_message() -> None: + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + channel._stream_bufs["!room:matrix.org"] = matrix_module._StreamBuf( + text="Final text", + event_id="event-1", + last_edit=100.0, + ) + + await channel.send_delta("!room:matrix.org", "", {"_stream_end": True}) + + assert "!room:matrix.org" not in channel._stream_bufs + assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS) + assert len(client.room_send_calls) == 1 + assert client.room_send_calls[0]["content"]["body"] == "Final text" + assert client.room_send_calls[0]["content"]["m.relates_to"] == { + "rel_type": "m.replace", + "event_id": "event-1", + } + + +@pytest.mark.asyncio +async def test_send_delta_stream_end_noop_when_buffer_missing() -> None: + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + await channel.send_delta("!room:matrix.org", "", {"_stream_end": True}) + + assert client.room_send_calls == [] + assert client.typing_calls == [] + + +@pytest.mark.asyncio +async def test_send_delta_on_error_stops_typing(monkeypatch) -> None: + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + client.raise_on_send = True + channel.client = client + + now = 100.0 + monkeypatch.setattr(channel, "monotonic_time", lambda: now) + + await channel.send_delta("!room:matrix.org", "Hello", {"room_id": "!room:matrix.org"}) + + assert "!room:matrix.org" in channel._stream_bufs + assert channel._stream_bufs["!room:matrix.org"].text == "Hello" + assert len(client.room_send_calls) == 1 + + assert len(client.typing_calls) == 1 + + +@pytest.mark.asyncio +async def test_send_delta_ignores_whitespace_only_delta(monkeypatch) -> None: + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + now = 100.0 + monkeypatch.setattr(channel, "monotonic_time", lambda: now) + + await channel.send_delta("!room:matrix.org", " ") + + assert "!room:matrix.org" in channel._stream_bufs + assert channel._stream_bufs["!room:matrix.org"].text == " " + assert client.room_send_calls == [] \ No newline at end of file From 0506e6c1c1fe908bbfca46408f5c8ff3b3ba8ab9 Mon Sep 17 00:00:00 2001 From: Paresh Mathur Date: Fri, 27 Mar 2026 02:51:45 +0100 Subject: [PATCH 29/91] feat(discord): Use `discord.py` for stable discord channel (#2486) Co-authored-by: Pares Mathur Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- nanobot/channels/discord.py | 665 +++++++++++++----------- nanobot/command/builtin.py | 17 +- pyproject.toml | 3 + tests/channels/test_discord_channel.py | 676 +++++++++++++++++++++++++ 4 files changed, 1061 insertions(+), 300 deletions(-) create mode 100644 tests/channels/test_discord_channel.py diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index 82eafcc0..ef7d41d7 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -1,25 +1,37 @@ -"""Discord channel implementation using Discord Gateway websocket.""" +"""Discord channel implementation using discord.py.""" + +from __future__ import annotations import asyncio -import json +import importlib.util from pathlib import Path -from typing import Any, Literal +from typing import TYPE_CHECKING, Any, Literal -import httpx -from pydantic import Field -import websockets from loguru import logger +from pydantic import Field from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel +from nanobot.command.builtin import build_help_text from nanobot.config.paths import get_media_dir from nanobot.config.schema import Base -from nanobot.utils.helpers import split_message +from nanobot.utils.helpers import safe_filename, split_message + +DISCORD_AVAILABLE = importlib.util.find_spec("discord") is not None +if TYPE_CHECKING: + import discord + from discord import app_commands + from discord.abc import Messageable + +if DISCORD_AVAILABLE: + import discord + from discord import app_commands + from discord.abc import Messageable -DISCORD_API_BASE = "https://discord.com/api/v10" MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 # 20MB MAX_MESSAGE_LEN = 2000 # Discord message character limit +TYPING_INTERVAL_S = 8 class DiscordConfig(Base): @@ -28,13 +40,202 @@ class DiscordConfig(Base): enabled: bool = False token: str = "" allow_from: list[str] = Field(default_factory=list) - gateway_url: str = "wss://gateway.discord.gg/?v=10&encoding=json" intents: int = 37377 group_policy: Literal["mention", "open"] = "mention" +if DISCORD_AVAILABLE: + + class DiscordBotClient(discord.Client): + """discord.py client that forwards events to the channel.""" + + def __init__(self, channel: DiscordChannel, *, intents: discord.Intents) -> None: + super().__init__(intents=intents) + self._channel = channel + self.tree = app_commands.CommandTree(self) + self._register_app_commands() + + async def on_ready(self) -> None: + self._channel._bot_user_id = str(self.user.id) if self.user else None + logger.info("Discord bot connected as user {}", self._channel._bot_user_id) + try: + synced = await self.tree.sync() + logger.info("Discord app commands synced: {}", len(synced)) + except Exception as e: + logger.warning("Discord app command sync failed: {}", e) + + async def on_message(self, message: discord.Message) -> None: + await self._channel._handle_discord_message(message) + + async def _reply_ephemeral(self, interaction: discord.Interaction, text: str) -> bool: + """Send an ephemeral interaction response and report success.""" + try: + await interaction.response.send_message(text, ephemeral=True) + return True + except Exception as e: + logger.warning("Discord interaction response failed: {}", e) + return False + + async def _forward_slash_command( + self, + interaction: discord.Interaction, + command_text: str, + ) -> None: + sender_id = str(interaction.user.id) + channel_id = interaction.channel_id + + if channel_id is None: + logger.warning("Discord slash command missing channel_id: {}", command_text) + return + + if not self._channel.is_allowed(sender_id): + await self._reply_ephemeral(interaction, "You are not allowed to use this bot.") + return + + await self._reply_ephemeral(interaction, f"Processing {command_text}...") + + await self._channel._handle_message( + sender_id=sender_id, + chat_id=str(channel_id), + content=command_text, + metadata={ + "interaction_id": str(interaction.id), + "guild_id": str(interaction.guild_id) if interaction.guild_id else None, + "is_slash_command": True, + }, + ) + + def _register_app_commands(self) -> None: + commands = ( + ("new", "Start a new conversation", "/new"), + ("stop", "Stop the current task", "/stop"), + ("restart", "Restart the bot", "/restart"), + ("status", "Show bot status", "/status"), + ) + + for name, description, command_text in commands: + @self.tree.command(name=name, description=description) + async def command_handler( + interaction: discord.Interaction, + _command_text: str = command_text, + ) -> None: + await self._forward_slash_command(interaction, _command_text) + + @self.tree.command(name="help", description="Show available commands") + async def help_command(interaction: discord.Interaction) -> None: + sender_id = str(interaction.user.id) + if not self._channel.is_allowed(sender_id): + await self._reply_ephemeral(interaction, "You are not allowed to use this bot.") + return + await self._reply_ephemeral(interaction, build_help_text()) + + @self.tree.error + async def on_app_command_error( + interaction: discord.Interaction, + error: app_commands.AppCommandError, + ) -> None: + command_name = interaction.command.qualified_name if interaction.command else "?" + logger.warning( + "Discord app command failed user={} channel={} cmd={} error={}", + interaction.user.id, + interaction.channel_id, + command_name, + error, + ) + + async def send_outbound(self, msg: OutboundMessage) -> None: + """Send a nanobot outbound message using Discord transport rules.""" + channel_id = int(msg.chat_id) + + channel = self.get_channel(channel_id) + if channel is None: + try: + channel = await self.fetch_channel(channel_id) + except Exception as e: + logger.warning("Discord channel {} unavailable: {}", msg.chat_id, e) + return + + reference, mention_settings = self._build_reply_context(channel, msg.reply_to) + sent_media = False + failed_media: list[str] = [] + + for index, media_path in enumerate(msg.media or []): + if await self._send_file( + channel, + media_path, + reference=reference if index == 0 else None, + mention_settings=mention_settings, + ): + sent_media = True + else: + failed_media.append(Path(media_path).name) + + for index, chunk in enumerate(self._build_chunks(msg.content or "", failed_media, sent_media)): + kwargs: dict[str, Any] = {"content": chunk} + if index == 0 and reference is not None and not sent_media: + kwargs["reference"] = reference + kwargs["allowed_mentions"] = mention_settings + await channel.send(**kwargs) + + async def _send_file( + self, + channel: Messageable, + file_path: str, + *, + reference: discord.PartialMessage | None, + mention_settings: discord.AllowedMentions, + ) -> bool: + """Send a file attachment via discord.py.""" + path = Path(file_path) + if not path.is_file(): + logger.warning("Discord file not found, skipping: {}", file_path) + return False + + if path.stat().st_size > MAX_ATTACHMENT_BYTES: + logger.warning("Discord file too large (>20MB), skipping: {}", path.name) + return False + + try: + kwargs: dict[str, Any] = {"file": discord.File(path)} + if reference is not None: + kwargs["reference"] = reference + kwargs["allowed_mentions"] = mention_settings + await channel.send(**kwargs) + logger.info("Discord file sent: {}", path.name) + return True + except Exception as e: + logger.error("Error sending Discord file {}: {}", path.name, e) + return False + + @staticmethod + def _build_chunks(content: str, failed_media: list[str], sent_media: bool) -> list[str]: + """Build outbound text chunks, including attachment-failure fallback text.""" + chunks = split_message(content, MAX_MESSAGE_LEN) + if chunks or not failed_media or sent_media: + return chunks + fallback = "\n".join(f"[attachment: {name} - send failed]" for name in failed_media) + return split_message(fallback, MAX_MESSAGE_LEN) + + @staticmethod + def _build_reply_context( + channel: Messageable, + reply_to: str | None, + ) -> tuple[discord.PartialMessage | None, discord.AllowedMentions]: + """Build reply context for outbound messages.""" + mention_settings = discord.AllowedMentions(replied_user=False) + if not reply_to: + return None, mention_settings + try: + message_id = int(reply_to) + except ValueError: + logger.warning("Invalid Discord reply target: {}", reply_to) + return None, mention_settings + + return channel.get_partial_message(message_id), mention_settings + + class DiscordChannel(BaseChannel): - """Discord channel using Gateway websocket.""" + """Discord channel using discord.py.""" name = "discord" display_name = "Discord" @@ -43,353 +244,229 @@ class DiscordChannel(BaseChannel): def default_config(cls) -> dict[str, Any]: return DiscordConfig().model_dump(by_alias=True) + @staticmethod + def _channel_key(channel_or_id: Any) -> str: + """Normalize channel-like objects and ids to a stable string key.""" + channel_id = getattr(channel_or_id, "id", channel_or_id) + return str(channel_id) + def __init__(self, config: Any, bus: MessageBus): if isinstance(config, dict): config = DiscordConfig.model_validate(config) super().__init__(config, bus) self.config: DiscordConfig = config - self._ws: websockets.WebSocketClientProtocol | None = None - self._seq: int | None = None - self._heartbeat_task: asyncio.Task | None = None - self._typing_tasks: dict[str, asyncio.Task] = {} - self._http: httpx.AsyncClient | None = None + self._client: DiscordBotClient | None = None + self._typing_tasks: dict[str, asyncio.Task[None]] = {} self._bot_user_id: str | None = None async def start(self) -> None: - """Start the Discord gateway connection.""" + """Start the Discord client.""" + if not DISCORD_AVAILABLE: + logger.error("discord.py not installed. Run: pip install nanobot-ai[discord]") + return + if not self.config.token: logger.error("Discord bot token not configured") return - self._running = True - self._http = httpx.AsyncClient(timeout=30.0) + try: + intents = discord.Intents.none() + intents.value = self.config.intents + self._client = DiscordBotClient(self, intents=intents) + except Exception as e: + logger.error("Failed to initialize Discord client: {}", e) + self._client = None + self._running = False + return - while self._running: - try: - logger.info("Connecting to Discord gateway...") - async with websockets.connect(self.config.gateway_url) as ws: - self._ws = ws - await self._gateway_loop() - except asyncio.CancelledError: - break - except Exception as e: - logger.warning("Discord gateway error: {}", e) - if self._running: - logger.info("Reconnecting to Discord gateway in 5 seconds...") - await asyncio.sleep(5) + self._running = True + logger.info("Starting Discord client via discord.py...") + + try: + await self._client.start(self.config.token) + except asyncio.CancelledError: + raise + except Exception as e: + logger.error("Discord client startup failed: {}", e) + finally: + self._running = False + await self._reset_runtime_state(close_client=True) async def stop(self) -> None: """Stop the Discord channel.""" self._running = False - if self._heartbeat_task: - self._heartbeat_task.cancel() - self._heartbeat_task = None - for task in self._typing_tasks.values(): - task.cancel() - self._typing_tasks.clear() - if self._ws: - await self._ws.close() - self._ws = None - if self._http: - await self._http.aclose() - self._http = None + await self._reset_runtime_state(close_client=True) async def send(self, msg: OutboundMessage) -> None: - """Send a message through Discord REST API, including file attachments.""" - if not self._http: - logger.warning("Discord HTTP client not initialized") + """Send a message through Discord using discord.py.""" + client = self._client + if client is None or not client.is_ready(): + logger.warning("Discord client not ready; dropping outbound message") return - url = f"{DISCORD_API_BASE}/channels/{msg.chat_id}/messages" - headers = {"Authorization": f"Bot {self.config.token}"} + is_progress = bool((msg.metadata or {}).get("_progress")) + try: + await client.send_outbound(msg) + except Exception as e: + logger.error("Error sending Discord message: {}", e) + finally: + if not is_progress: + await self._stop_typing(msg.chat_id) + + async def _handle_discord_message(self, message: discord.Message) -> None: + """Handle incoming Discord messages from discord.py.""" + if message.author.bot: + return + + sender_id = str(message.author.id) + channel_id = self._channel_key(message.channel) + content = message.content or "" + + if not self._should_accept_inbound(message, sender_id, content): + return + + media_paths, attachment_markers = await self._download_attachments(message.attachments) + full_content = self._compose_inbound_content(content, attachment_markers) + metadata = self._build_inbound_metadata(message) + + await self._start_typing(message.channel) try: - sent_media = False - failed_media: list[str] = [] + await self._handle_message( + sender_id=sender_id, + chat_id=channel_id, + content=full_content, + media=media_paths, + metadata=metadata, + ) + except Exception: + await self._stop_typing(channel_id) + raise - # Send file attachments first - for media_path in msg.media or []: - if await self._send_file(url, headers, media_path, reply_to=msg.reply_to): - sent_media = True - else: - failed_media.append(Path(media_path).name) + async def _on_message(self, message: discord.Message) -> None: + """Backward-compatible alias for legacy tests/callers.""" + await self._handle_discord_message(message) - # Send text content - chunks = split_message(msg.content or "", MAX_MESSAGE_LEN) - if not chunks and failed_media and not sent_media: - chunks = split_message( - "\n".join(f"[attachment: {name} - send failed]" for name in failed_media), - MAX_MESSAGE_LEN, - ) - if not chunks: - return - - for i, chunk in enumerate(chunks): - payload: dict[str, Any] = {"content": chunk} - - # Let the first successful attachment carry the reply if present. - if i == 0 and msg.reply_to and not sent_media: - payload["message_reference"] = {"message_id": msg.reply_to} - payload["allowed_mentions"] = {"replied_user": False} - - if not await self._send_payload(url, headers, payload): - break # Abort remaining chunks on failure - finally: - await self._stop_typing(msg.chat_id) - - async def _send_payload( - self, url: str, headers: dict[str, str], payload: dict[str, Any] - ) -> bool: - """Send a single Discord API payload with retry on rate-limit. Returns True on success.""" - for attempt in range(3): - try: - response = await self._http.post(url, headers=headers, json=payload) - if response.status_code == 429: - data = response.json() - retry_after = float(data.get("retry_after", 1.0)) - logger.warning("Discord rate limited, retrying in {}s", retry_after) - await asyncio.sleep(retry_after) - continue - response.raise_for_status() - return True - except Exception as e: - if attempt == 2: - logger.error("Error sending Discord message: {}", e) - else: - await asyncio.sleep(1) - return False - - async def _send_file( + def _should_accept_inbound( self, - url: str, - headers: dict[str, str], - file_path: str, - reply_to: str | None = None, + message: discord.Message, + sender_id: str, + content: str, ) -> bool: - """Send a file attachment via Discord REST API using multipart/form-data.""" - path = Path(file_path) - if not path.is_file(): - logger.warning("Discord file not found, skipping: {}", file_path) - return False - - if path.stat().st_size > MAX_ATTACHMENT_BYTES: - logger.warning("Discord file too large (>20MB), skipping: {}", path.name) - return False - - payload_json: dict[str, Any] = {} - if reply_to: - payload_json["message_reference"] = {"message_id": reply_to} - payload_json["allowed_mentions"] = {"replied_user": False} - - for attempt in range(3): - try: - with open(path, "rb") as f: - files = {"files[0]": (path.name, f, "application/octet-stream")} - data: dict[str, Any] = {} - if payload_json: - data["payload_json"] = json.dumps(payload_json) - response = await self._http.post( - url, headers=headers, files=files, data=data - ) - if response.status_code == 429: - resp_data = response.json() - retry_after = float(resp_data.get("retry_after", 1.0)) - logger.warning("Discord rate limited, retrying in {}s", retry_after) - await asyncio.sleep(retry_after) - continue - response.raise_for_status() - logger.info("Discord file sent: {}", path.name) - return True - except Exception as e: - if attempt == 2: - logger.error("Error sending Discord file {}: {}", path.name, e) - else: - await asyncio.sleep(1) - return False - - async def _gateway_loop(self) -> None: - """Main gateway loop: identify, heartbeat, dispatch events.""" - if not self._ws: - return - - async for raw in self._ws: - try: - data = json.loads(raw) - except json.JSONDecodeError: - logger.warning("Invalid JSON from Discord gateway: {}", raw[:100]) - continue - - op = data.get("op") - event_type = data.get("t") - seq = data.get("s") - payload = data.get("d") - - if seq is not None: - self._seq = seq - - if op == 10: - # HELLO: start heartbeat and identify - interval_ms = payload.get("heartbeat_interval", 45000) - await self._start_heartbeat(interval_ms / 1000) - await self._identify() - elif op == 0 and event_type == "READY": - logger.info("Discord gateway READY") - # Capture bot user ID for mention detection - user_data = payload.get("user") or {} - self._bot_user_id = user_data.get("id") - logger.info("Discord bot connected as user {}", self._bot_user_id) - elif op == 0 and event_type == "MESSAGE_CREATE": - await self._handle_message_create(payload) - elif op == 7: - # RECONNECT: exit loop to reconnect - logger.info("Discord gateway requested reconnect") - break - elif op == 9: - # INVALID_SESSION: reconnect - logger.warning("Discord gateway invalid session") - break - - async def _identify(self) -> None: - """Send IDENTIFY payload.""" - if not self._ws: - return - - identify = { - "op": 2, - "d": { - "token": self.config.token, - "intents": self.config.intents, - "properties": { - "os": "nanobot", - "browser": "nanobot", - "device": "nanobot", - }, - }, - } - await self._ws.send(json.dumps(identify)) - - async def _start_heartbeat(self, interval_s: float) -> None: - """Start or restart the heartbeat loop.""" - if self._heartbeat_task: - self._heartbeat_task.cancel() - - async def heartbeat_loop() -> None: - while self._running and self._ws: - payload = {"op": 1, "d": self._seq} - try: - await self._ws.send(json.dumps(payload)) - except Exception as e: - logger.warning("Discord heartbeat failed: {}", e) - break - await asyncio.sleep(interval_s) - - self._heartbeat_task = asyncio.create_task(heartbeat_loop()) - - async def _handle_message_create(self, payload: dict[str, Any]) -> None: - """Handle incoming Discord messages.""" - author = payload.get("author") or {} - if author.get("bot"): - return - - sender_id = str(author.get("id", "")) - channel_id = str(payload.get("channel_id", "")) - content = payload.get("content") or "" - guild_id = payload.get("guild_id") - - if not sender_id or not channel_id: - return - + """Check if inbound Discord message should be processed.""" if not self.is_allowed(sender_id): - return + return False + if message.guild is not None and not self._should_respond_in_group(message, content): + return False + return True - # Check group channel policy (DMs always respond if is_allowed passes) - if guild_id is not None: - if not self._should_respond_in_group(payload, content): - return - - content_parts = [content] if content else [] + async def _download_attachments( + self, + attachments: list[discord.Attachment], + ) -> tuple[list[str], list[str]]: + """Download supported attachments and return paths + display markers.""" media_paths: list[str] = [] + markers: list[str] = [] media_dir = get_media_dir("discord") - for attachment in payload.get("attachments") or []: - url = attachment.get("url") - filename = attachment.get("filename") or "attachment" - size = attachment.get("size") or 0 - if not url or not self._http: - continue - if size and size > MAX_ATTACHMENT_BYTES: - content_parts.append(f"[attachment: {filename} - too large]") + for attachment in attachments: + filename = attachment.filename or "attachment" + if attachment.size and attachment.size > MAX_ATTACHMENT_BYTES: + markers.append(f"[attachment: {filename} - too large]") continue try: media_dir.mkdir(parents=True, exist_ok=True) - file_path = media_dir / f"{attachment.get('id', 'file')}_{filename.replace('/', '_')}" - resp = await self._http.get(url) - resp.raise_for_status() - file_path.write_bytes(resp.content) + safe_name = safe_filename(filename) + file_path = media_dir / f"{attachment.id}_{safe_name}" + await attachment.save(file_path) media_paths.append(str(file_path)) - content_parts.append(f"[attachment: {file_path}]") + markers.append(f"[attachment: {file_path.name}]") except Exception as e: logger.warning("Failed to download Discord attachment: {}", e) - content_parts.append(f"[attachment: {filename} - download failed]") + markers.append(f"[attachment: {filename} - download failed]") - reply_to = (payload.get("referenced_message") or {}).get("id") + return media_paths, markers - await self._start_typing(channel_id) + @staticmethod + def _compose_inbound_content(content: str, attachment_markers: list[str]) -> str: + """Combine message text with attachment markers.""" + content_parts = [content] if content else [] + content_parts.extend(attachment_markers) + return "\n".join(part for part in content_parts if part) or "[empty message]" - await self._handle_message( - sender_id=sender_id, - chat_id=channel_id, - content="\n".join(p for p in content_parts if p) or "[empty message]", - media=media_paths, - metadata={ - "message_id": str(payload.get("id", "")), - "guild_id": guild_id, - "reply_to": reply_to, - }, - ) + @staticmethod + def _build_inbound_metadata(message: discord.Message) -> dict[str, str | None]: + """Build metadata for inbound Discord messages.""" + reply_to = str(message.reference.message_id) if message.reference and message.reference.message_id else None + return { + "message_id": str(message.id), + "guild_id": str(message.guild.id) if message.guild else None, + "reply_to": reply_to, + } - def _should_respond_in_group(self, payload: dict[str, Any], content: str) -> bool: - """Check if bot should respond in a group channel based on policy.""" + def _should_respond_in_group(self, message: discord.Message, content: str) -> bool: + """Check if the bot should respond in a guild channel based on policy.""" if self.config.group_policy == "open": return True if self.config.group_policy == "mention": - # Check if bot was mentioned in the message - if self._bot_user_id: - # Check mentions array - mentions = payload.get("mentions") or [] - for mention in mentions: - if str(mention.get("id")) == self._bot_user_id: - return True - # Also check content for mention format <@USER_ID> - if f"<@{self._bot_user_id}>" in content or f"<@!{self._bot_user_id}>" in content: - return True - logger.debug("Discord message in {} ignored (bot not mentioned)", payload.get("channel_id")) + bot_user_id = self._bot_user_id + if bot_user_id is None: + logger.debug("Discord message in {} ignored (bot identity unavailable)", message.channel.id) + return False + + if any(str(user.id) == bot_user_id for user in message.mentions): + return True + if f"<@{bot_user_id}>" in content or f"<@!{bot_user_id}>" in content: + return True + + logger.debug("Discord message in {} ignored (bot not mentioned)", message.channel.id) return False return True - async def _start_typing(self, channel_id: str) -> None: + async def _start_typing(self, channel: Messageable) -> None: """Start periodic typing indicator for a channel.""" + channel_id = self._channel_key(channel) await self._stop_typing(channel_id) async def typing_loop() -> None: - url = f"{DISCORD_API_BASE}/channels/{channel_id}/typing" - headers = {"Authorization": f"Bot {self.config.token}"} while self._running: try: - await self._http.post(url, headers=headers) + async with channel.typing(): + await asyncio.sleep(TYPING_INTERVAL_S) except asyncio.CancelledError: return except Exception as e: logger.debug("Discord typing indicator failed for {}: {}", channel_id, e) return - await asyncio.sleep(8) self._typing_tasks[channel_id] = asyncio.create_task(typing_loop()) async def _stop_typing(self, channel_id: str) -> None: """Stop typing indicator for a channel.""" - task = self._typing_tasks.pop(channel_id, None) - if task: - task.cancel() + task = self._typing_tasks.pop(self._channel_key(channel_id), None) + if task is None: + return + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + async def _cancel_all_typing(self) -> None: + """Stop all typing tasks.""" + channel_ids = list(self._typing_tasks) + for channel_id in channel_ids: + await self._stop_typing(channel_id) + + async def _reset_runtime_state(self, close_client: bool) -> None: + """Reset client and typing state.""" + await self._cancel_all_typing() + if close_client and self._client is not None and not self._client.is_closed(): + try: + await self._client.close() + except Exception as e: + logger.warning("Discord client close failed: {}", e) + self._client = None + self._bot_user_id = None diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index 0a9af3cb..64339705 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -84,6 +84,16 @@ async def cmd_new(ctx: CommandContext) -> OutboundMessage: async def cmd_help(ctx: CommandContext) -> OutboundMessage: """Return available slash commands.""" + return OutboundMessage( + channel=ctx.msg.channel, + chat_id=ctx.msg.chat_id, + content=build_help_text(), + metadata={"render_as": "text"}, + ) + + +def build_help_text() -> str: + """Build canonical help text shared across channels.""" lines = [ "🐈 nanobot commands:", "/new — Start a new conversation", @@ -92,12 +102,7 @@ async def cmd_help(ctx: CommandContext) -> OutboundMessage: "/status — Show bot status", "/help — Show available commands", ] - return OutboundMessage( - channel=ctx.msg.channel, - chat_id=ctx.msg.chat_id, - content="\n".join(lines), - metadata={"render_as": "text"}, - ) + return "\n".join(lines) def register_builtin_commands(router: CommandRouter) -> None: diff --git a/pyproject.toml b/pyproject.toml index 8298d112..51d49466 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,9 @@ matrix = [ "mistune>=3.0.0,<4.0.0", "nh3>=0.2.17,<1.0.0", ] +discord = [ + "discord.py>=2.5.2,<3.0.0", +] langsmith = [ "langsmith>=0.1.0", ] diff --git a/tests/channels/test_discord_channel.py b/tests/channels/test_discord_channel.py new file mode 100644 index 00000000..3f1f996f --- /dev/null +++ b/tests/channels/test_discord_channel.py @@ -0,0 +1,676 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from types import SimpleNamespace + +import discord +import pytest + +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.channels.discord import DiscordBotClient, DiscordChannel, DiscordConfig +from nanobot.command.builtin import build_help_text + + +# Minimal Discord client test double used to control startup/readiness behavior. +class _FakeDiscordClient: + instances: list["_FakeDiscordClient"] = [] + start_error: Exception | None = None + + def __init__(self, owner, *, intents) -> None: + self.owner = owner + self.intents = intents + self.closed = False + self.ready = True + self.channels: dict[int, object] = {} + self.user = SimpleNamespace(id=999) + self.__class__.instances.append(self) + + async def start(self, token: str) -> None: + self.token = token + if self.__class__.start_error is not None: + raise self.__class__.start_error + + async def close(self) -> None: + self.closed = True + + def is_closed(self) -> bool: + return self.closed + + def is_ready(self) -> bool: + return self.ready + + def get_channel(self, channel_id: int): + return self.channels.get(channel_id) + + async def send_outbound(self, msg: OutboundMessage) -> None: + channel = self.get_channel(int(msg.chat_id)) + if channel is None: + return + await channel.send(content=msg.content) + + +class _FakeAttachment: + # Attachment double that can simulate successful or failing save() calls. + def __init__(self, attachment_id: int, filename: str, *, size: int = 1, fail: bool = False) -> None: + self.id = attachment_id + self.filename = filename + self.size = size + self._fail = fail + + async def save(self, path: str | Path) -> None: + if self._fail: + raise RuntimeError("save failed") + Path(path).write_bytes(b"attachment") + + +class _FakePartialMessage: + # Lightweight stand-in for Discord partial message references used in replies. + def __init__(self, message_id: int) -> None: + self.id = message_id + + +class _FakeChannel: + # Channel double that records outbound payloads and typing activity. + def __init__(self, channel_id: int = 123) -> None: + self.id = channel_id + self.sent_payloads: list[dict] = [] + self.trigger_typing_calls = 0 + self.typing_enter_hook = None + + async def send(self, **kwargs) -> None: + payload = dict(kwargs) + if "file" in payload: + payload["file_name"] = payload["file"].filename + del payload["file"] + self.sent_payloads.append(payload) + + def get_partial_message(self, message_id: int) -> _FakePartialMessage: + return _FakePartialMessage(message_id) + + def typing(self): + channel = self + + class _TypingContext: + async def __aenter__(self): + channel.trigger_typing_calls += 1 + if channel.typing_enter_hook is not None: + await channel.typing_enter_hook() + + async def __aexit__(self, exc_type, exc, tb): + return False + + return _TypingContext() + + +class _FakeInteractionResponse: + def __init__(self) -> None: + self.messages: list[dict] = [] + self._done = False + + async def send_message(self, content: str, *, ephemeral: bool = False) -> None: + self.messages.append({"content": content, "ephemeral": ephemeral}) + self._done = True + + def is_done(self) -> bool: + return self._done + + +def _make_interaction( + *, + user_id: int = 123, + channel_id: int | None = 456, + guild_id: int | None = None, + interaction_id: int = 999, +): + return SimpleNamespace( + user=SimpleNamespace(id=user_id), + channel_id=channel_id, + guild_id=guild_id, + id=interaction_id, + command=SimpleNamespace(qualified_name="new"), + response=_FakeInteractionResponse(), + ) + + +def _make_message( + *, + author_id: int = 123, + author_bot: bool = False, + channel_id: int = 456, + message_id: int = 789, + content: str = "hello", + guild_id: int | None = None, + mentions: list[object] | None = None, + attachments: list[object] | None = None, + reply_to: int | None = None, +): + # Factory for incoming Discord message objects with optional guild/reply/attachments. + guild = SimpleNamespace(id=guild_id) if guild_id is not None else None + reference = SimpleNamespace(message_id=reply_to) if reply_to is not None else None + return SimpleNamespace( + author=SimpleNamespace(id=author_id, bot=author_bot), + channel=_FakeChannel(channel_id), + content=content, + guild=guild, + mentions=mentions or [], + attachments=attachments or [], + reference=reference, + id=message_id, + ) + + +@pytest.mark.asyncio +async def test_start_returns_when_token_missing() -> None: + # If no token is configured, startup should no-op and leave channel stopped. + channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus()) + + await channel.start() + + assert channel.is_running is False + assert channel._client is None + + +@pytest.mark.asyncio +async def test_start_returns_when_discord_dependency_missing(monkeypatch) -> None: + channel = DiscordChannel( + DiscordConfig(enabled=True, token="token", allow_from=["*"]), + MessageBus(), + ) + monkeypatch.setattr("nanobot.channels.discord.DISCORD_AVAILABLE", False) + + await channel.start() + + assert channel.is_running is False + assert channel._client is None + + +@pytest.mark.asyncio +async def test_start_handles_client_construction_failure(monkeypatch) -> None: + # Construction errors from the Discord client should be swallowed and keep state clean. + channel = DiscordChannel( + DiscordConfig(enabled=True, token="token", allow_from=["*"]), + MessageBus(), + ) + + def _boom(owner, *, intents): + raise RuntimeError("bad client") + + monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _boom) + + await channel.start() + + assert channel.is_running is False + assert channel._client is None + + +@pytest.mark.asyncio +async def test_start_handles_client_start_failure(monkeypatch) -> None: + # If client.start fails, the partially created client should be closed and detached. + channel = DiscordChannel( + DiscordConfig(enabled=True, token="token", allow_from=["*"]), + MessageBus(), + ) + + _FakeDiscordClient.instances.clear() + _FakeDiscordClient.start_error = RuntimeError("connect failed") + monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _FakeDiscordClient) + + await channel.start() + + assert channel.is_running is False + assert channel._client is None + assert _FakeDiscordClient.instances[0].intents.value == channel.config.intents + assert _FakeDiscordClient.instances[0].closed is True + + _FakeDiscordClient.start_error = None + + +@pytest.mark.asyncio +async def test_stop_is_safe_after_partial_start(monkeypatch) -> None: + # stop() should close/discard the client even when startup was only partially completed. + channel = DiscordChannel( + DiscordConfig(enabled=True, token="token", allow_from=["*"]), + MessageBus(), + ) + client = _FakeDiscordClient(channel, intents=None) + channel._client = client + channel._running = True + + await channel.stop() + + assert channel.is_running is False + assert client.closed is True + assert channel._client is None + + +@pytest.mark.asyncio +async def test_on_message_ignores_bot_messages() -> None: + # Incoming bot-authored messages must be ignored to prevent feedback loops. + channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus()) + handled: list[dict] = [] + channel._handle_message = lambda **kwargs: handled.append(kwargs) # type: ignore[method-assign] + + await channel._on_message(_make_message(author_bot=True)) + + assert handled == [] + + # If inbound handling raises, typing should be stopped for that channel. + async def fail_handle(**kwargs) -> None: + raise RuntimeError("boom") + + channel._handle_message = fail_handle # type: ignore[method-assign] + + with pytest.raises(RuntimeError, match="boom"): + await channel._on_message(_make_message(author_id=123, channel_id=456)) + + assert channel._typing_tasks == {} + + +@pytest.mark.asyncio +async def test_on_message_accepts_allowlisted_dm() -> None: + # Allowed direct messages should be forwarded with normalized metadata. + channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["123"]), MessageBus()) + handled: list[dict] = [] + + async def capture_handle(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = capture_handle # type: ignore[method-assign] + + await channel._on_message(_make_message(author_id=123, channel_id=456, message_id=789)) + + assert len(handled) == 1 + assert handled[0]["chat_id"] == "456" + assert handled[0]["metadata"] == {"message_id": "789", "guild_id": None, "reply_to": None} + + +@pytest.mark.asyncio +async def test_on_message_ignores_unmentioned_guild_message() -> None: + # With mention-only group policy, guild messages without a bot mention are dropped. + channel = DiscordChannel( + DiscordConfig(enabled=True, allow_from=["*"], group_policy="mention"), + MessageBus(), + ) + channel._bot_user_id = "999" + handled: list[dict] = [] + + async def capture_handle(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = capture_handle # type: ignore[method-assign] + + await channel._on_message(_make_message(guild_id=1, content="hello everyone")) + + assert handled == [] + + +@pytest.mark.asyncio +async def test_on_message_accepts_mentioned_guild_message() -> None: + # Mentioned guild messages should be accepted and preserve reply threading metadata. + channel = DiscordChannel( + DiscordConfig(enabled=True, allow_from=["*"], group_policy="mention"), + MessageBus(), + ) + channel._bot_user_id = "999" + handled: list[dict] = [] + + async def capture_handle(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = capture_handle # type: ignore[method-assign] + + await channel._on_message( + _make_message( + guild_id=1, + content="<@999> hello", + mentions=[SimpleNamespace(id=999)], + reply_to=321, + ) + ) + + assert len(handled) == 1 + assert handled[0]["metadata"]["reply_to"] == "321" + + +@pytest.mark.asyncio +async def test_on_message_downloads_attachments(tmp_path, monkeypatch) -> None: + # Attachment downloads should be saved and referenced in forwarded content/media. + channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus()) + handled: list[dict] = [] + + async def capture_handle(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = capture_handle # type: ignore[method-assign] + monkeypatch.setattr("nanobot.channels.discord.get_media_dir", lambda _name: tmp_path) + + await channel._on_message( + _make_message( + attachments=[_FakeAttachment(12, "photo.png")], + content="see file", + ) + ) + + assert len(handled) == 1 + assert handled[0]["media"] == [str(tmp_path / "12_photo.png")] + assert "[attachment:" in handled[0]["content"] + + +@pytest.mark.asyncio +async def test_on_message_marks_failed_attachment_download(tmp_path, monkeypatch) -> None: + # Failed attachment downloads should emit a readable placeholder and no media path. + channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus()) + handled: list[dict] = [] + + async def capture_handle(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = capture_handle # type: ignore[method-assign] + monkeypatch.setattr("nanobot.channels.discord.get_media_dir", lambda _name: tmp_path) + + await channel._on_message( + _make_message( + attachments=[_FakeAttachment(12, "photo.png", fail=True)], + content="", + ) + ) + + assert len(handled) == 1 + assert handled[0]["media"] == [] + assert handled[0]["content"] == "[attachment: photo.png - download failed]" + + +@pytest.mark.asyncio +async def test_send_warns_when_client_not_ready() -> None: + # Sending without a running/ready client should be a safe no-op. + channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus()) + + await channel.send(OutboundMessage(channel="discord", chat_id="123", content="hello")) + + assert channel._typing_tasks == {} + + +@pytest.mark.asyncio +async def test_send_skips_when_channel_not_cached() -> None: + # Outbound sends should be skipped when the destination channel is not resolvable. + owner = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus()) + client = DiscordBotClient(owner, intents=discord.Intents.none()) + fetch_calls: list[int] = [] + + async def fetch_channel(channel_id: int): + fetch_calls.append(channel_id) + raise RuntimeError("not found") + + client.fetch_channel = fetch_channel # type: ignore[method-assign] + + await client.send_outbound(OutboundMessage(channel="discord", chat_id="123", content="hello")) + + assert client.get_channel(123) is None + assert fetch_calls == [123] + + +@pytest.mark.asyncio +async def test_send_fetches_channel_when_not_cached() -> None: + owner = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus()) + client = DiscordBotClient(owner, intents=discord.Intents.none()) + target = _FakeChannel(channel_id=123) + + async def fetch_channel(channel_id: int): + return target if channel_id == 123 else None + + client.fetch_channel = fetch_channel # type: ignore[method-assign] + + await client.send_outbound(OutboundMessage(channel="discord", chat_id="123", content="hello")) + + assert target.sent_payloads == [{"content": "hello"}] + + +@pytest.mark.asyncio +async def test_slash_new_forwards_when_user_is_allowlisted() -> None: + channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["123"]), MessageBus()) + handled: list[dict] = [] + + async def capture_handle(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = capture_handle # type: ignore[method-assign] + client = DiscordBotClient(channel, intents=discord.Intents.none()) + interaction = _make_interaction(user_id=123, channel_id=456, interaction_id=321) + + new_cmd = client.tree.get_command("new") + assert new_cmd is not None + await new_cmd.callback(interaction) + + assert interaction.response.messages == [ + {"content": "Processing /new...", "ephemeral": True} + ] + assert len(handled) == 1 + assert handled[0]["content"] == "/new" + assert handled[0]["sender_id"] == "123" + assert handled[0]["chat_id"] == "456" + assert handled[0]["metadata"]["interaction_id"] == "321" + assert handled[0]["metadata"]["is_slash_command"] is True + + +@pytest.mark.asyncio +async def test_slash_new_is_blocked_for_disallowed_user() -> None: + channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["999"]), MessageBus()) + handled: list[dict] = [] + + async def capture_handle(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = capture_handle # type: ignore[method-assign] + client = DiscordBotClient(channel, intents=discord.Intents.none()) + interaction = _make_interaction(user_id=123, channel_id=456) + + new_cmd = client.tree.get_command("new") + assert new_cmd is not None + await new_cmd.callback(interaction) + + assert interaction.response.messages == [ + {"content": "You are not allowed to use this bot.", "ephemeral": True} + ] + assert handled == [] + + +@pytest.mark.parametrize("slash_name", ["stop", "restart", "status"]) +@pytest.mark.asyncio +async def test_slash_commands_forward_via_handle_message(slash_name: str) -> None: + channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus()) + handled: list[dict] = [] + + async def capture_handle(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = capture_handle # type: ignore[method-assign] + client = DiscordBotClient(channel, intents=discord.Intents.none()) + interaction = _make_interaction() + interaction.command.qualified_name = slash_name + + cmd = client.tree.get_command(slash_name) + assert cmd is not None + await cmd.callback(interaction) + + assert interaction.response.messages == [ + {"content": f"Processing /{slash_name}...", "ephemeral": True} + ] + assert len(handled) == 1 + assert handled[0]["content"] == f"/{slash_name}" + assert handled[0]["metadata"]["is_slash_command"] is True + + +@pytest.mark.asyncio +async def test_slash_help_returns_ephemeral_help_text() -> None: + channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus()) + handled: list[dict] = [] + + async def capture_handle(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = capture_handle # type: ignore[method-assign] + client = DiscordBotClient(channel, intents=discord.Intents.none()) + interaction = _make_interaction() + interaction.command.qualified_name = "help" + + help_cmd = client.tree.get_command("help") + assert help_cmd is not None + await help_cmd.callback(interaction) + + assert interaction.response.messages == [ + {"content": build_help_text(), "ephemeral": True} + ] + assert handled == [] + + +@pytest.mark.asyncio +async def test_client_send_outbound_chunks_text_replies_and_uploads_files(tmp_path) -> None: + # Outbound payloads should upload files, attach reply references, and chunk long text. + owner = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus()) + client = DiscordBotClient(owner, intents=discord.Intents.none()) + target = _FakeChannel(channel_id=123) + client.get_channel = lambda channel_id: target if channel_id == 123 else None # type: ignore[method-assign] + + file_path = tmp_path / "demo.txt" + file_path.write_text("hi") + + await client.send_outbound( + OutboundMessage( + channel="discord", + chat_id="123", + content="a" * 2100, + reply_to="55", + media=[str(file_path)], + ) + ) + + assert len(target.sent_payloads) == 3 + assert target.sent_payloads[0]["file_name"] == "demo.txt" + assert target.sent_payloads[0]["reference"].id == 55 + assert target.sent_payloads[1]["content"] == "a" * 2000 + assert target.sent_payloads[2]["content"] == "a" * 100 + + +@pytest.mark.asyncio +async def test_client_send_outbound_reports_failed_attachments_when_no_text(tmp_path) -> None: + # If all attachment sends fail and no text exists, emit a failure placeholder message. + owner = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus()) + client = DiscordBotClient(owner, intents=discord.Intents.none()) + target = _FakeChannel(channel_id=123) + client.get_channel = lambda channel_id: target if channel_id == 123 else None # type: ignore[method-assign] + + missing_file = tmp_path / "missing.txt" + + await client.send_outbound( + OutboundMessage( + channel="discord", + chat_id="123", + content="", + media=[str(missing_file)], + ) + ) + + assert target.sent_payloads == [{"content": "[attachment: missing.txt - send failed]"}] + + +@pytest.mark.asyncio +async def test_send_stops_typing_after_send() -> None: + # Active typing indicators should be cancelled/cleared after a successful send. + channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus()) + client = _FakeDiscordClient(channel, intents=None) + channel._client = client + channel._running = True + + start = asyncio.Event() + release = asyncio.Event() + + async def slow_typing() -> None: + start.set() + await release.wait() + + typing_channel = _FakeChannel(channel_id=123) + typing_channel.typing_enter_hook = slow_typing + + await channel._start_typing(typing_channel) + await start.wait() + + await channel.send(OutboundMessage(channel="discord", chat_id="123", content="hello")) + release.set() + await asyncio.sleep(0) + + assert channel._typing_tasks == {} + + # Progress messages should keep typing active until a final (non-progress) send. + start = asyncio.Event() + release = asyncio.Event() + + async def slow_typing_progress() -> None: + start.set() + await release.wait() + + typing_channel = _FakeChannel(channel_id=123) + typing_channel.typing_enter_hook = slow_typing_progress + + await channel._start_typing(typing_channel) + await start.wait() + + await channel.send( + OutboundMessage( + channel="discord", + chat_id="123", + content="progress", + metadata={"_progress": True}, + ) + ) + + assert "123" in channel._typing_tasks + + await channel.send(OutboundMessage(channel="discord", chat_id="123", content="final")) + release.set() + await asyncio.sleep(0) + + assert channel._typing_tasks == {} + + +@pytest.mark.asyncio +async def test_start_typing_uses_typing_context_when_trigger_typing_missing() -> None: + channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus()) + channel._running = True + + entered = asyncio.Event() + release = asyncio.Event() + + class _TypingCtx: + async def __aenter__(self): + entered.set() + + async def __aexit__(self, exc_type, exc, tb): + return False + + class _NoTriggerChannel: + def __init__(self, channel_id: int = 123) -> None: + self.id = channel_id + + def typing(self): + async def _waiter(): + await release.wait() + # Hold the loop so task remains active until explicitly stopped. + class _Ctx(_TypingCtx): + async def __aenter__(self): + await super().__aenter__() + await _waiter() + return _Ctx() + + typing_channel = _NoTriggerChannel(channel_id=123) + await channel._start_typing(typing_channel) # type: ignore[arg-type] + await entered.wait() + + assert "123" in channel._typing_tasks + + await channel._stop_typing("123") + release.set() + await asyncio.sleep(0) + + assert channel._typing_tasks == {} From 8956df3668de0e0b009275aa38d88049535b3cd6 Mon Sep 17 00:00:00 2001 From: Jesse <74103710+95256155o@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:02:43 -0400 Subject: [PATCH 30/91] feat(discord): configurable read receipt + subagent working indicator (#2330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(discord): channel-side read receipt and subagent indicator - Add 👀 reaction on message receipt, removed after bot reply - Add 🔧 reaction on first progress message, removed on final reply - Both managed purely in discord.py channel layer, no subagent.py changes - Config: read_receipt_emoji, subagent_emoji with sensible defaults Addresses maintainer feedback on HKUDS/nanobot#2330 Co-Authored-By: Claude Sonnet 4.6 * fix(discord): add both reactions on inbound, not on progress _progress flag is for streaming chunks, not subagent lifecycle. Add 👀 + 🔧 immediately on message receipt, clear both on final reply. * fix: remove stale _subagent_active reference in _clear_reactions * fix(discord): clean up reactions on message handling failure Previously, if _handle_message raised an exception, pending reactions (read receipt + subagent indicator) would remain on the user's message indefinitely since send() — which handles normal cleanup — would never be called. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(discord): replace subagent_emoji with delayed working indicator - Rename subagent_emoji → working_emoji (honest naming: not tied to subagent lifecycle) - Add working_emoji_delay (default 2s) — cosmetic delay so 🔧 appears after 👀, cancelled if bot replies before delay fires - Clean up: cancel pending task + remove both reactions on reply/error Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Sonnet 4.6 --- nanobot/channels/discord.py | 44 +++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index ef7d41d7..9bf4d919 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -42,6 +42,9 @@ class DiscordConfig(Base): allow_from: list[str] = Field(default_factory=list) intents: int = 37377 group_policy: Literal["mention", "open"] = "mention" + read_receipt_emoji: str = "👀" + working_emoji: str = "🔧" + working_emoji_delay: float = 2.0 if DISCORD_AVAILABLE: @@ -258,6 +261,8 @@ class DiscordChannel(BaseChannel): self._client: DiscordBotClient | None = None self._typing_tasks: dict[str, asyncio.Task[None]] = {} self._bot_user_id: str | None = None + self._pending_reactions: dict[str, Any] = {} # chat_id -> message object + self._working_emoji_tasks: dict[str, asyncio.Task[None]] = {} async def start(self) -> None: """Start the Discord client.""" @@ -305,6 +310,7 @@ class DiscordChannel(BaseChannel): return is_progress = bool((msg.metadata or {}).get("_progress")) + try: await client.send_outbound(msg) except Exception as e: @@ -312,6 +318,7 @@ class DiscordChannel(BaseChannel): finally: if not is_progress: await self._stop_typing(msg.chat_id) + await self._clear_reactions(msg.chat_id) async def _handle_discord_message(self, message: discord.Message) -> None: """Handle incoming Discord messages from discord.py.""" @@ -331,6 +338,24 @@ class DiscordChannel(BaseChannel): await self._start_typing(message.channel) + # Add read receipt reaction immediately, working emoji after delay + channel_id = self._channel_key(message.channel) + try: + await message.add_reaction(self.config.read_receipt_emoji) + self._pending_reactions[channel_id] = message + except Exception as e: + logger.debug("Failed to add read receipt reaction: {}", e) + + # Delayed working indicator (cosmetic — not tied to subagent lifecycle) + async def _delayed_working_emoji() -> None: + await asyncio.sleep(self.config.working_emoji_delay) + try: + await message.add_reaction(self.config.working_emoji) + except Exception: + pass + + self._working_emoji_tasks[channel_id] = asyncio.create_task(_delayed_working_emoji()) + try: await self._handle_message( sender_id=sender_id, @@ -340,6 +365,7 @@ class DiscordChannel(BaseChannel): metadata=metadata, ) except Exception: + await self._clear_reactions(channel_id) await self._stop_typing(channel_id) raise @@ -454,6 +480,24 @@ class DiscordChannel(BaseChannel): except asyncio.CancelledError: pass + + async def _clear_reactions(self, chat_id: str) -> None: + """Remove all pending reactions after bot replies.""" + # Cancel delayed working emoji if it hasn't fired yet + task = self._working_emoji_tasks.pop(chat_id, None) + if task and not task.done(): + task.cancel() + + msg_obj = self._pending_reactions.pop(chat_id, None) + if msg_obj is None: + return + bot_user = self._client.user if self._client else None + for emoji in (self.config.read_receipt_emoji, self.config.working_emoji): + try: + await msg_obj.remove_reaction(emoji, bot_user) + except Exception: + pass + async def _cancel_all_typing(self) -> None: """Stop all typing tasks.""" channel_ids = list(self._typing_tasks) From f450c6ef6c0ca9afc2c03c91fd727e94f28464a6 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Tue, 31 Mar 2026 11:18:18 +0000 Subject: [PATCH 31/91] fix(channel): preserve threaded streaming context --- nanobot/agent/loop.py | 18 +++--- nanobot/channels/matrix.py | 35 ++++++++--- tests/agent/test_task_cancel.py | 37 ++++++++++++ tests/channels/test_discord_channel.py | 2 +- tests/channels/test_matrix_channel.py | 82 ++++++++++++++++++++++++++ 5 files changed, 155 insertions(+), 19 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 97d352cb..a9dc589e 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -403,25 +403,25 @@ class AgentLoop: return f"{stream_base_id}:{stream_segment}" async def on_stream(delta: str) -> None: + meta = dict(msg.metadata or {}) + meta["_stream_delta"] = True + meta["_stream_id"] = _current_stream_id() await self.bus.publish_outbound(OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content=delta, - metadata={ - "_stream_delta": True, - "_stream_id": _current_stream_id(), - }, + metadata=meta, )) async def on_stream_end(*, resuming: bool = False) -> None: nonlocal stream_segment + meta = dict(msg.metadata or {}) + meta["_stream_end"] = True + meta["_resuming"] = resuming + meta["_stream_id"] = _current_stream_id() await self.bus.publish_outbound(OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content="", - metadata={ - "_stream_end": True, - "_resuming": resuming, - "_stream_id": _current_stream_id(), - }, + metadata=meta, )) stream_segment += 1 diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index dcece104..bc6d9398 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -132,7 +132,11 @@ def _render_markdown_html(text: str) -> str | None: return formatted -def _build_matrix_text_content(text: str, event_id: str | None = None) -> dict[str, object]: +def _build_matrix_text_content( + text: str, + event_id: str | None = None, + thread_relates_to: dict[str, object] | None = None, +) -> dict[str, object]: """ Constructs and returns a dictionary representing the matrix text content with optional HTML formatting and reference to an existing event for replacement. This function is @@ -144,6 +148,9 @@ def _build_matrix_text_content(text: str, event_id: str | None = None) -> dict[s include information indicating that the message is a replacement of the specified event. :type event_id: str | None + :param thread_relates_to: Optional Matrix thread relation metadata. For edits this is + stored in ``m.new_content`` so the replacement remains in the same thread. + :type thread_relates_to: dict[str, object] | None :return: A dictionary containing the matrix text content, potentially enriched with HTML formatting and replacement metadata if applicable. :rtype: dict[str, object] @@ -153,14 +160,18 @@ def _build_matrix_text_content(text: str, event_id: str | None = None) -> dict[s content["format"] = MATRIX_HTML_FORMAT content["formatted_body"] = html if event_id: - content["m.new_content"] = { + content["m.new_content"] = { "body": text, - "msgtype": "m.text" + "msgtype": "m.text", } content["m.relates_to"] = { "rel_type": "m.replace", - "event_id": event_id + "event_id": event_id, } + if thread_relates_to: + content["m.new_content"]["m.relates_to"] = thread_relates_to + elif thread_relates_to: + content["m.relates_to"] = thread_relates_to return content @@ -475,9 +486,11 @@ class MatrixChannel(BaseChannel): await self._stop_typing_keepalive(chat_id, clear_typing=True) - content = _build_matrix_text_content(buf.text, buf.event_id) - if relates_to: - content["m.relates_to"] = relates_to + content = _build_matrix_text_content( + buf.text, + buf.event_id, + thread_relates_to=relates_to, + ) await self._send_room_content(chat_id, content) return @@ -494,14 +507,18 @@ class MatrixChannel(BaseChannel): if not buf.last_edit or (now - buf.last_edit) >= self._STREAM_EDIT_INTERVAL: try: - content = _build_matrix_text_content(buf.text, buf.event_id) + content = _build_matrix_text_content( + buf.text, + buf.event_id, + thread_relates_to=relates_to, + ) response = await self._send_room_content(chat_id, content) buf.last_edit = now if not buf.event_id: # we are editing the same message all the time, so only the first time the event id needs to be set buf.event_id = response.event_id except Exception: - await self._stop_typing_keepalive(metadata["room_id"], clear_typing=True) + await self._stop_typing_keepalive(chat_id, clear_typing=True) pass diff --git a/tests/agent/test_task_cancel.py b/tests/agent/test_task_cancel.py index 4902a4c8..70f7621d 100644 --- a/tests/agent/test_task_cancel.py +++ b/tests/agent/test_task_cancel.py @@ -117,6 +117,43 @@ class TestDispatch: out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0) assert out.content == "hi" + @pytest.mark.asyncio + async def test_dispatch_streaming_preserves_message_metadata(self): + from nanobot.bus.events import InboundMessage + + loop, bus = _make_loop() + msg = InboundMessage( + channel="matrix", + sender_id="u1", + chat_id="!room:matrix.org", + content="hello", + metadata={ + "_wants_stream": True, + "thread_root_event_id": "$root1", + "thread_reply_to_event_id": "$reply1", + }, + ) + + async def fake_process(_msg, *, on_stream=None, on_stream_end=None, **kwargs): + assert on_stream is not None + assert on_stream_end is not None + await on_stream("hi") + await on_stream_end(resuming=False) + return None + + loop._process_message = fake_process + + await loop._dispatch(msg) + first = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0) + second = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0) + + assert first.metadata["thread_root_event_id"] == "$root1" + assert first.metadata["thread_reply_to_event_id"] == "$reply1" + assert first.metadata["_stream_delta"] is True + assert second.metadata["thread_root_event_id"] == "$root1" + assert second.metadata["thread_reply_to_event_id"] == "$reply1" + assert second.metadata["_stream_end"] is True + @pytest.mark.asyncio async def test_processing_lock_serializes(self): from nanobot.bus.events import InboundMessage, OutboundMessage diff --git a/tests/channels/test_discord_channel.py b/tests/channels/test_discord_channel.py index 3f1f996f..d352c788 100644 --- a/tests/channels/test_discord_channel.py +++ b/tests/channels/test_discord_channel.py @@ -4,8 +4,8 @@ import asyncio from pathlib import Path from types import SimpleNamespace -import discord import pytest +discord = pytest.importorskip("discord") from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus diff --git a/tests/channels/test_matrix_channel.py b/tests/channels/test_matrix_channel.py index 3ad65e76..18a8e109 100644 --- a/tests/channels/test_matrix_channel.py +++ b/tests/channels/test_matrix_channel.py @@ -1367,6 +1367,23 @@ def test_build_matrix_text_content_with_event_id() -> None: assert result["m.relates_to"]["event_id"] == event_id +def test_build_matrix_text_content_with_event_id_preserves_thread_relation() -> None: + """Thread relations for edits should stay inside m.new_content.""" + relates_to = { + "rel_type": "m.thread", + "event_id": "$root1", + "m.in_reply_to": {"event_id": "$reply1"}, + "is_falling_back": True, + } + result = _build_matrix_text_content("Updated message", "event-1", relates_to) + + assert result["m.relates_to"] == { + "rel_type": "m.replace", + "event_id": "event-1", + } + assert result["m.new_content"]["m.relates_to"] == relates_to + + def test_build_matrix_text_content_no_event_id() -> None: """Test that when event_id is not provided, no extra properties are added.""" result = _build_matrix_text_content("Regular message") @@ -1500,6 +1517,71 @@ async def test_send_delta_stream_end_replaces_existing_message() -> None: } +@pytest.mark.asyncio +async def test_send_delta_starts_threaded_stream_inside_thread() -> None: + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + client.room_send_response.event_id = "event-1" + + metadata = { + "thread_root_event_id": "$root1", + "thread_reply_to_event_id": "$reply1", + } + await channel.send_delta("!room:matrix.org", "Hello", metadata) + + assert client.room_send_calls[0]["content"]["m.relates_to"] == { + "rel_type": "m.thread", + "event_id": "$root1", + "m.in_reply_to": {"event_id": "$reply1"}, + "is_falling_back": True, + } + + +@pytest.mark.asyncio +async def test_send_delta_threaded_edit_keeps_replace_and_thread_relation(monkeypatch) -> None: + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + client.room_send_response.event_id = "event-1" + + times = [100.0, 102.0, 104.0] + times.reverse() + monkeypatch.setattr(channel, "monotonic_time", lambda: times and times.pop()) + + metadata = { + "thread_root_event_id": "$root1", + "thread_reply_to_event_id": "$reply1", + } + await channel.send_delta("!room:matrix.org", "Hello", metadata) + await channel.send_delta("!room:matrix.org", " world", metadata) + await channel.send_delta("!room:matrix.org", "", {"_stream_end": True, **metadata}) + + edit_content = client.room_send_calls[1]["content"] + final_content = client.room_send_calls[2]["content"] + + assert edit_content["m.relates_to"] == { + "rel_type": "m.replace", + "event_id": "event-1", + } + assert edit_content["m.new_content"]["m.relates_to"] == { + "rel_type": "m.thread", + "event_id": "$root1", + "m.in_reply_to": {"event_id": "$reply1"}, + "is_falling_back": True, + } + assert final_content["m.relates_to"] == { + "rel_type": "m.replace", + "event_id": "event-1", + } + assert final_content["m.new_content"]["m.relates_to"] == { + "rel_type": "m.thread", + "event_id": "$root1", + "m.in_reply_to": {"event_id": "$reply1"}, + "is_falling_back": True, + } + + @pytest.mark.asyncio async def test_send_delta_stream_end_noop_when_buffer_missing() -> None: channel = MatrixChannel(_make_config(), MessageBus()) From bc8fbd1ce4496b87860f6a6d334a116a1b4fb6ce Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Tue, 31 Mar 2026 11:34:33 +0000 Subject: [PATCH 32/91] fix(weixin): reset QR poll host after refresh --- nanobot/channels/weixin.py | 1 + tests/channels/test_weixin_channel.py | 35 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py index c6c1603a..891cfd09 100644 --- a/nanobot/channels/weixin.py +++ b/nanobot/channels/weixin.py @@ -385,6 +385,7 @@ class WeixinChannel(BaseChannel): ) return False qrcode_id, scan_url = await self._fetch_qr_code() + current_poll_base_url = self.config.base_url self._print_qr_code(scan_url) continue # status == "wait" — keep polling diff --git a/tests/channels/test_weixin_channel.py b/tests/channels/test_weixin_channel.py index 515eaa28..58fc3086 100644 --- a/tests/channels/test_weixin_channel.py +++ b/tests/channels/test_weixin_channel.py @@ -519,6 +519,41 @@ async def test_qr_login_redirect_without_host_keeps_current_polling_base_url() - assert second_call.kwargs["base_url"] == "https://ilinkai.weixin.qq.com" +@pytest.mark.asyncio +async def test_qr_login_resets_redirect_base_url_after_qr_refresh() -> None: + channel, _bus = _make_channel() + channel._running = True + channel._save_state = lambda: None + channel._print_qr_code = lambda url: None + channel._fetch_qr_code = AsyncMock(side_effect=[("qr-1", "url-1"), ("qr-2", "url-2")]) + + channel._api_get_with_base = AsyncMock( + side_effect=[ + {"status": "scaned_but_redirect", "redirect_host": "idc.redirect.test"}, + {"status": "expired"}, + { + "status": "confirmed", + "bot_token": "token-5", + "ilink_bot_id": "bot-5", + "baseurl": "https://example.test", + "ilink_user_id": "wx-user", + }, + ] + ) + + ok = await channel._qr_login() + + assert ok is True + assert channel._token == "token-5" + assert channel._api_get_with_base.await_count == 3 + first_call = channel._api_get_with_base.await_args_list[0] + second_call = channel._api_get_with_base.await_args_list[1] + third_call = channel._api_get_with_base.await_args_list[2] + assert first_call.kwargs["base_url"] == "https://ilinkai.weixin.qq.com" + assert second_call.kwargs["base_url"] == "https://idc.redirect.test" + assert third_call.kwargs["base_url"] == "https://ilinkai.weixin.qq.com" + + @pytest.mark.asyncio async def test_process_message_skips_bot_messages() -> None: channel, bus = _make_channel() From 5bdb7a90b12eb62b133af96e3bdea43bd5d1a574 Mon Sep 17 00:00:00 2001 From: xcosmosbox <2162381070@qq.com> Date: Sun, 29 Mar 2026 13:01:44 +0800 Subject: [PATCH 33/91] feat(weixin): 1.align protocol headers with package.json metadata 2.support upload_full_url with fallback to upload_param --- nanobot/channels/weixin.py | 66 +++++++++++++++++++++------ tests/channels/test_weixin_channel.py | 64 +++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 14 deletions(-) diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py index f09ef95f..3b62a726 100644 --- a/nanobot/channels/weixin.py +++ b/nanobot/channels/weixin.py @@ -53,7 +53,41 @@ MESSAGE_TYPE_BOT = 2 MESSAGE_STATE_FINISH = 2 WEIXIN_MAX_MESSAGE_LEN = 4000 -WEIXIN_CHANNEL_VERSION = "1.0.3" + + +def _read_reference_package_meta() -> dict[str, str]: + """Best-effort read of reference `package/package.json` metadata.""" + try: + pkg_path = Path(__file__).resolve().parents[2] / "package" / "package.json" + data = json.loads(pkg_path.read_text(encoding="utf-8")) + return { + "version": str(data.get("version", "") or ""), + "ilink_appid": str(data.get("ilink_appid", "") or ""), + } + except Exception: + return {"version": "", "ilink_appid": ""} + + +def _build_client_version(version: str) -> int: + """Encode semantic version as 0x00MMNNPP (major/minor/patch in one uint32).""" + parts = version.split(".") + + def _as_int(idx: int) -> int: + try: + return int(parts[idx]) + except Exception: + return 0 + + major = _as_int(0) + minor = _as_int(1) + patch = _as_int(2) + return ((major & 0xFF) << 16) | ((minor & 0xFF) << 8) | (patch & 0xFF) + + +_PKG_META = _read_reference_package_meta() +WEIXIN_CHANNEL_VERSION = _PKG_META["version"] or "unknown" +ILINK_APP_ID = _PKG_META["ilink_appid"] +ILINK_APP_CLIENT_VERSION = _build_client_version(_PKG_META["version"] or "0.0.0") BASE_INFO: dict[str, str] = {"channel_version": WEIXIN_CHANNEL_VERSION} # Session-expired error code @@ -199,6 +233,8 @@ class WeixinChannel(BaseChannel): "X-WECHAT-UIN": self._random_wechat_uin(), "Content-Type": "application/json", "AuthorizationType": "ilink_bot_token", + "iLink-App-Id": ILINK_APP_ID, + "iLink-App-ClientVersion": str(ILINK_APP_CLIENT_VERSION), } if auth and self._token: headers["Authorization"] = f"Bearer {self._token}" @@ -267,13 +303,10 @@ class WeixinChannel(BaseChannel): logger.info("Waiting for QR code scan...") while self._running: try: - # Reference plugin sends iLink-App-ClientVersion header for - # QR status polling (login-qr.ts:81). status_data = await self._api_get( "ilink/bot/get_qrcode_status", params={"qrcode": qrcode_id}, auth=False, - extra_headers={"iLink-App-ClientVersion": "1"}, ) except httpx.TimeoutException: continue @@ -838,7 +871,7 @@ class WeixinChannel(BaseChannel): # Matches aesEcbPaddedSize: Math.ceil((size + 1) / 16) * 16 padded_size = ((raw_size + 1 + 15) // 16) * 16 - # Step 1: Get upload URL (upload_param) from server + # Step 1: Get upload URL from server (prefer upload_full_url, fallback to upload_param) file_key = os.urandom(16).hex() upload_body: dict[str, Any] = { "filekey": file_key, @@ -855,19 +888,26 @@ class WeixinChannel(BaseChannel): upload_resp = await self._api_post("ilink/bot/getuploadurl", upload_body) logger.debug("WeChat getuploadurl response: {}", upload_resp) - upload_param = upload_resp.get("upload_param", "") - if not upload_param: - raise RuntimeError(f"getuploadurl returned no upload_param: {upload_resp}") + upload_full_url = str(upload_resp.get("upload_full_url", "") or "").strip() + upload_param = str(upload_resp.get("upload_param", "") or "") + if not upload_full_url and not upload_param: + raise RuntimeError( + "getuploadurl returned no upload URL " + f"(need upload_full_url or upload_param): {upload_resp}" + ) # Step 2: AES-128-ECB encrypt and POST to CDN aes_key_b64 = base64.b64encode(aes_key_raw).decode() encrypted_data = _encrypt_aes_ecb(raw_data, aes_key_b64) - cdn_upload_url = ( - f"{self.config.cdn_base_url}/upload" - f"?encrypted_query_param={quote(upload_param)}" - f"&filekey={quote(file_key)}" - ) + if upload_full_url: + cdn_upload_url = upload_full_url + else: + cdn_upload_url = ( + f"{self.config.cdn_base_url}/upload" + f"?encrypted_query_param={quote(upload_param)}" + f"&filekey={quote(file_key)}" + ) logger.debug("WeChat CDN POST url={} ciphertextSize={}", cdn_upload_url[:80], len(encrypted_data)) cdn_resp = await self._client.post( diff --git a/tests/channels/test_weixin_channel.py b/tests/channels/test_weixin_channel.py index 54d9bd93..498e49e9 100644 --- a/tests/channels/test_weixin_channel.py +++ b/tests/channels/test_weixin_channel.py @@ -1,6 +1,7 @@ import asyncio import json import tempfile +from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock @@ -42,10 +43,13 @@ def test_make_headers_includes_route_tag_when_configured() -> None: assert headers["Authorization"] == "Bearer token" assert headers["SKRouteTag"] == "123" + assert headers["iLink-App-Id"] == "bot" + assert headers["iLink-App-ClientVersion"] == str((2 << 16) | (1 << 8) | 1) def test_channel_version_matches_reference_plugin_version() -> None: - assert WEIXIN_CHANNEL_VERSION == "1.0.3" + pkg = json.loads(Path("package/package.json").read_text()) + assert WEIXIN_CHANNEL_VERSION == pkg["version"] def test_save_and_load_state_persists_context_tokens(tmp_path) -> None: @@ -278,3 +282,61 @@ async def test_process_message_skips_bot_messages() -> None: ) assert bus.inbound_size == 0 + + +class _DummyHttpResponse: + def __init__(self, *, headers: dict[str, str] | None = None, status_code: int = 200) -> None: + self.headers = headers or {} + self.status_code = status_code + + def raise_for_status(self) -> None: + return None + + +@pytest.mark.asyncio +async def test_send_media_uses_upload_full_url_when_present(tmp_path) -> None: + channel, _bus = _make_channel() + + media_file = tmp_path / "photo.jpg" + media_file.write_bytes(b"hello-weixin") + + cdn_post = AsyncMock(return_value=_DummyHttpResponse(headers={"x-encrypted-param": "dl-param"})) + channel._client = SimpleNamespace(post=cdn_post) + channel._api_post = AsyncMock( + side_effect=[ + { + "upload_full_url": "https://upload-full.example.test/path?foo=bar", + "upload_param": "should-not-be-used", + }, + {"ret": 0}, + ] + ) + + await channel._send_media_file("wx-user", str(media_file), "ctx-1") + + # first POST call is CDN upload + cdn_url = cdn_post.await_args_list[0].args[0] + assert cdn_url == "https://upload-full.example.test/path?foo=bar" + + +@pytest.mark.asyncio +async def test_send_media_falls_back_to_upload_param_url(tmp_path) -> None: + channel, _bus = _make_channel() + + media_file = tmp_path / "photo.jpg" + media_file.write_bytes(b"hello-weixin") + + cdn_post = AsyncMock(return_value=_DummyHttpResponse(headers={"x-encrypted-param": "dl-param"})) + channel._client = SimpleNamespace(post=cdn_post) + channel._api_post = AsyncMock( + side_effect=[ + {"upload_param": "enc-need-fallback"}, + {"ret": 0}, + ] + ) + + await channel._send_media_file("wx-user", str(media_file), "ctx-1") + + cdn_url = cdn_post.await_args_list[0].args[0] + assert cdn_url.startswith(f"{channel.config.cdn_base_url}/upload?encrypted_query_param=enc-need-fallback") + assert "&filekey=" in cdn_url From 3823042290ec0aa9c3bc90be168f1b0ceeaebc95 Mon Sep 17 00:00:00 2001 From: xcosmosbox <2162381070@qq.com> Date: Sun, 29 Mar 2026 13:14:22 +0800 Subject: [PATCH 34/91] fix(weixin): correct PKCS7 unpadding for AES-ECB; support full_url for media download --- nanobot/channels/weixin.py | 56 +++++++++++++++++------- tests/channels/test_weixin_channel.py | 63 +++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 16 deletions(-) diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py index 3b62a726..c829512b 100644 --- a/nanobot/channels/weixin.py +++ b/nanobot/channels/weixin.py @@ -685,9 +685,10 @@ class WeixinChannel(BaseChannel): """Download + AES-decrypt a media item. Returns local path or None.""" try: media = typed_item.get("media") or {} - encrypt_query_param = media.get("encrypt_query_param", "") + encrypt_query_param = str(media.get("encrypt_query_param", "") or "") + full_url = str(media.get("full_url", "") or "").strip() - if not encrypt_query_param: + if not encrypt_query_param and not full_url: return None # Resolve AES key (media-download.ts:43-45, pic-decrypt.ts:40-52) @@ -704,11 +705,14 @@ class WeixinChannel(BaseChannel): elif media_aes_key_b64: aes_key_b64 = media_aes_key_b64 - # Build CDN download URL with proper URL-encoding (cdn-url.ts:7) - cdn_url = ( - f"{self.config.cdn_base_url}/download" - f"?encrypted_query_param={quote(encrypt_query_param)}" - ) + # Prefer server-provided full_url, fallback to encrypted_query_param URL construction. + if full_url: + cdn_url = full_url + else: + cdn_url = ( + f"{self.config.cdn_base_url}/download" + f"?encrypted_query_param={quote(encrypt_query_param)}" + ) assert self._client is not None resp = await self._client.get(cdn_url) @@ -727,7 +731,8 @@ class WeixinChannel(BaseChannel): ext = _ext_for_type(media_type) if not filename: ts = int(time.time()) - h = abs(hash(encrypt_query_param)) % 100000 + hash_seed = encrypt_query_param or full_url + h = abs(hash(hash_seed)) % 100000 filename = f"{media_type}_{ts}_{h}{ext}" safe_name = os.path.basename(filename) file_path = media_dir / safe_name @@ -1045,23 +1050,42 @@ def _decrypt_aes_ecb(data: bytes, aes_key_b64: str) -> bytes: logger.warning("Failed to parse AES key, returning raw data: {}", e) return data + decrypted: bytes | None = None + try: from Crypto.Cipher import AES cipher = AES.new(key, AES.MODE_ECB) - return cipher.decrypt(data) # pycryptodome auto-strips PKCS7 with unpad + decrypted = cipher.decrypt(data) except ImportError: pass - try: - from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + if decrypted is None: + try: + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes - cipher_obj = Cipher(algorithms.AES(key), modes.ECB()) - decryptor = cipher_obj.decryptor() - return decryptor.update(data) + decryptor.finalize() - except ImportError: - logger.warning("Cannot decrypt media: install 'pycryptodome' or 'cryptography'") + cipher_obj = Cipher(algorithms.AES(key), modes.ECB()) + decryptor = cipher_obj.decryptor() + decrypted = decryptor.update(data) + decryptor.finalize() + except ImportError: + logger.warning("Cannot decrypt media: install 'pycryptodome' or 'cryptography'") + return data + + return _pkcs7_unpad_safe(decrypted) + + +def _pkcs7_unpad_safe(data: bytes, block_size: int = 16) -> bytes: + """Safely remove PKCS7 padding when valid; otherwise return original bytes.""" + if not data: return data + if len(data) % block_size != 0: + return data + pad_len = data[-1] + if pad_len < 1 or pad_len > block_size: + return data + if data[-pad_len:] != bytes([pad_len]) * pad_len: + return data + return data[:-pad_len] def _ext_for_type(media_type: str) -> str: diff --git a/tests/channels/test_weixin_channel.py b/tests/channels/test_weixin_channel.py index 498e49e9..a52aaa80 100644 --- a/tests/channels/test_weixin_channel.py +++ b/tests/channels/test_weixin_channel.py @@ -7,12 +7,15 @@ from unittest.mock import AsyncMock import pytest +import nanobot.channels.weixin as weixin_mod from nanobot.bus.queue import MessageBus from nanobot.channels.weixin import ( ITEM_IMAGE, ITEM_TEXT, MESSAGE_TYPE_BOT, WEIXIN_CHANNEL_VERSION, + _decrypt_aes_ecb, + _encrypt_aes_ecb, WeixinChannel, WeixinConfig, ) @@ -340,3 +343,63 @@ async def test_send_media_falls_back_to_upload_param_url(tmp_path) -> None: cdn_url = cdn_post.await_args_list[0].args[0] assert cdn_url.startswith(f"{channel.config.cdn_base_url}/upload?encrypted_query_param=enc-need-fallback") assert "&filekey=" in cdn_url + + +def test_decrypt_aes_ecb_strips_valid_pkcs7_padding() -> None: + key_b64 = "MDEyMzQ1Njc4OWFiY2RlZg==" # base64("0123456789abcdef") + plaintext = b"hello-weixin-padding" + + ciphertext = _encrypt_aes_ecb(plaintext, key_b64) + decrypted = _decrypt_aes_ecb(ciphertext, key_b64) + + assert decrypted == plaintext + + +class _DummyDownloadResponse: + def __init__(self, content: bytes, status_code: int = 200) -> None: + self.content = content + self.status_code = status_code + + def raise_for_status(self) -> None: + return None + + +@pytest.mark.asyncio +async def test_download_media_item_uses_full_url_when_present(tmp_path) -> None: + channel, _bus = _make_channel() + weixin_mod.get_media_dir = lambda _name: tmp_path + + full_url = "https://cdn.example.test/download/full" + channel._client = SimpleNamespace( + get=AsyncMock(return_value=_DummyDownloadResponse(content=b"raw-image-bytes")) + ) + + item = { + "media": { + "full_url": full_url, + "encrypt_query_param": "enc-fallback-should-not-be-used", + }, + } + saved_path = await channel._download_media_item(item, "image") + + assert saved_path is not None + assert Path(saved_path).read_bytes() == b"raw-image-bytes" + channel._client.get.assert_awaited_once_with(full_url) + + +@pytest.mark.asyncio +async def test_download_media_item_falls_back_to_encrypt_query_param(tmp_path) -> None: + channel, _bus = _make_channel() + weixin_mod.get_media_dir = lambda _name: tmp_path + + channel._client = SimpleNamespace( + get=AsyncMock(return_value=_DummyDownloadResponse(content=b"fallback-bytes")) + ) + + item = {"media": {"encrypt_query_param": "enc-fallback"}} + saved_path = await channel._download_media_item(item, "image") + + assert saved_path is not None + assert Path(saved_path).read_bytes() == b"fallback-bytes" + called_url = channel._client.get.await_args_list[0].args[0] + assert called_url.startswith(f"{channel.config.cdn_base_url}/download?encrypted_query_param=enc-fallback") From efd42cc236a2fb1a79f873da1731007a51b64f92 Mon Sep 17 00:00:00 2001 From: xcosmosbox <2162381070@qq.com> Date: Sun, 29 Mar 2026 13:37:22 +0800 Subject: [PATCH 35/91] feat(weixin): implement QR redirect handling --- nanobot/channels/weixin.py | 42 +++++++++++++- tests/channels/test_weixin_channel.py | 80 +++++++++++++++++++++++++-- 2 files changed, 116 insertions(+), 6 deletions(-) diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py index c829512b..51cef15e 100644 --- a/nanobot/channels/weixin.py +++ b/nanobot/channels/weixin.py @@ -259,6 +259,25 @@ class WeixinChannel(BaseChannel): resp.raise_for_status() return resp.json() + async def _api_get_with_base( + self, + *, + base_url: str, + endpoint: str, + params: dict | None = None, + auth: bool = True, + extra_headers: dict[str, str] | None = None, + ) -> dict: + """GET helper that allows overriding base_url for QR redirect polling.""" + assert self._client is not None + url = f"{base_url.rstrip('/')}/{endpoint}" + hdrs = self._make_headers(auth=auth) + if extra_headers: + hdrs.update(extra_headers) + resp = await self._client.get(url, params=params, headers=hdrs) + resp.raise_for_status() + return resp.json() + async def _api_post( self, endpoint: str, @@ -299,12 +318,14 @@ class WeixinChannel(BaseChannel): refresh_count = 0 qrcode_id, scan_url = await self._fetch_qr_code() self._print_qr_code(scan_url) + current_poll_base_url = self.config.base_url logger.info("Waiting for QR code scan...") while self._running: try: - status_data = await self._api_get( - "ilink/bot/get_qrcode_status", + status_data = await self._api_get_with_base( + base_url=current_poll_base_url, + endpoint="ilink/bot/get_qrcode_status", params={"qrcode": qrcode_id}, auth=False, ) @@ -333,6 +354,23 @@ class WeixinChannel(BaseChannel): return False elif status == "scaned": logger.info("QR code scanned, waiting for confirmation...") + elif status == "scaned_but_redirect": + redirect_host = str(status_data.get("redirect_host", "") or "").strip() + if redirect_host: + if redirect_host.startswith("http://") or redirect_host.startswith("https://"): + redirected_base = redirect_host + else: + redirected_base = f"https://{redirect_host}" + if redirected_base != current_poll_base_url: + logger.info( + "QR status redirect: switching polling host to {}", + redirected_base, + ) + current_poll_base_url = redirected_base + else: + logger.warning( + "QR status returned scaned_but_redirect but redirect_host is missing", + ) elif status == "expired": refresh_count += 1 if refresh_count > MAX_QR_REFRESH_COUNT: diff --git a/tests/channels/test_weixin_channel.py b/tests/channels/test_weixin_channel.py index a52aaa80..076be610 100644 --- a/tests/channels/test_weixin_channel.py +++ b/tests/channels/test_weixin_channel.py @@ -227,8 +227,12 @@ async def test_qr_login_refreshes_expired_qr_and_then_succeeds() -> None: channel._api_get = AsyncMock( side_effect=[ {"qrcode": "qr-1", "qrcode_img_content": "url-1"}, - {"status": "expired"}, {"qrcode": "qr-2", "qrcode_img_content": "url-2"}, + ] + ) + channel._api_get_with_base = AsyncMock( + side_effect=[ + {"status": "expired"}, { "status": "confirmed", "bot_token": "token-2", @@ -254,12 +258,16 @@ async def test_qr_login_returns_false_after_too_many_expired_qr_codes() -> None: channel._api_get = AsyncMock( side_effect=[ {"qrcode": "qr-1", "qrcode_img_content": "url-1"}, - {"status": "expired"}, {"qrcode": "qr-2", "qrcode_img_content": "url-2"}, - {"status": "expired"}, {"qrcode": "qr-3", "qrcode_img_content": "url-3"}, - {"status": "expired"}, {"qrcode": "qr-4", "qrcode_img_content": "url-4"}, + ] + ) + channel._api_get_with_base = AsyncMock( + side_effect=[ + {"status": "expired"}, + {"status": "expired"}, + {"status": "expired"}, {"status": "expired"}, ] ) @@ -269,6 +277,70 @@ async def test_qr_login_returns_false_after_too_many_expired_qr_codes() -> None: assert ok is False +@pytest.mark.asyncio +async def test_qr_login_switches_polling_base_url_on_redirect_status() -> None: + channel, _bus = _make_channel() + channel._running = True + channel._save_state = lambda: None + channel._print_qr_code = lambda url: None + channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1")) + + status_side_effect = [ + {"status": "scaned_but_redirect", "redirect_host": "idc.redirect.test"}, + { + "status": "confirmed", + "bot_token": "token-3", + "ilink_bot_id": "bot-3", + "baseurl": "https://example.test", + "ilink_user_id": "wx-user", + }, + ] + channel._api_get = AsyncMock(side_effect=list(status_side_effect)) + channel._api_get_with_base = AsyncMock(side_effect=list(status_side_effect)) + + ok = await channel._qr_login() + + assert ok is True + assert channel._token == "token-3" + assert channel._api_get_with_base.await_count == 2 + first_call = channel._api_get_with_base.await_args_list[0] + second_call = channel._api_get_with_base.await_args_list[1] + assert first_call.kwargs["base_url"] == "https://ilinkai.weixin.qq.com" + assert second_call.kwargs["base_url"] == "https://idc.redirect.test" + + +@pytest.mark.asyncio +async def test_qr_login_redirect_without_host_keeps_current_polling_base_url() -> None: + channel, _bus = _make_channel() + channel._running = True + channel._save_state = lambda: None + channel._print_qr_code = lambda url: None + channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1")) + + status_side_effect = [ + {"status": "scaned_but_redirect"}, + { + "status": "confirmed", + "bot_token": "token-4", + "ilink_bot_id": "bot-4", + "baseurl": "https://example.test", + "ilink_user_id": "wx-user", + }, + ] + channel._api_get = AsyncMock(side_effect=list(status_side_effect)) + channel._api_get_with_base = AsyncMock(side_effect=list(status_side_effect)) + + ok = await channel._qr_login() + + assert ok is True + assert channel._token == "token-4" + assert channel._api_get_with_base.await_count == 2 + first_call = channel._api_get_with_base.await_args_list[0] + second_call = channel._api_get_with_base.await_args_list[1] + assert first_call.kwargs["base_url"] == "https://ilinkai.weixin.qq.com" + assert second_call.kwargs["base_url"] == "https://ilinkai.weixin.qq.com" + + @pytest.mark.asyncio async def test_process_message_skips_bot_messages() -> None: channel, bus = _make_channel() From faf2b07923848e2ace54d6785a3ede668316c33d Mon Sep 17 00:00:00 2001 From: xcosmosbox <2162381070@qq.com> Date: Sun, 29 Mar 2026 15:19:57 +0800 Subject: [PATCH 36/91] feat(weixin): add fallback logic for referenced media download --- nanobot/channels/weixin.py | 46 +++++++++++++++++ tests/channels/test_weixin_channel.py | 74 +++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py index 51cef15e..6324290f 100644 --- a/nanobot/channels/weixin.py +++ b/nanobot/channels/weixin.py @@ -691,6 +691,52 @@ class WeixinChannel(BaseChannel): else: content_parts.append("[video]") + # Fallback: when no top-level media was downloaded, try quoted/referenced media. + # This aligns with the reference plugin behavior that checks ref_msg.message_item + # when main item_list has no downloadable media. + if not media_paths: + ref_media_item: dict[str, Any] | None = None + for item in item_list: + if item.get("type", 0) != ITEM_TEXT: + continue + ref = item.get("ref_msg") or {} + candidate = ref.get("message_item") or {} + if candidate.get("type", 0) in (ITEM_IMAGE, ITEM_VOICE, ITEM_FILE, ITEM_VIDEO): + ref_media_item = candidate + break + + if ref_media_item: + ref_type = ref_media_item.get("type", 0) + if ref_type == ITEM_IMAGE: + image_item = ref_media_item.get("image_item") or {} + file_path = await self._download_media_item(image_item, "image") + if file_path: + content_parts.append(f"[image]\n[Image: source: {file_path}]") + media_paths.append(file_path) + elif ref_type == ITEM_VOICE: + voice_item = ref_media_item.get("voice_item") or {} + file_path = await self._download_media_item(voice_item, "voice") + if file_path: + transcription = await self.transcribe_audio(file_path) + if transcription: + content_parts.append(f"[voice] {transcription}") + else: + content_parts.append(f"[voice]\n[Audio: source: {file_path}]") + media_paths.append(file_path) + elif ref_type == ITEM_FILE: + file_item = ref_media_item.get("file_item") or {} + file_name = file_item.get("file_name", "unknown") + file_path = await self._download_media_item(file_item, "file", file_name) + if file_path: + content_parts.append(f"[file: {file_name}]\n[File: source: {file_path}]") + media_paths.append(file_path) + elif ref_type == ITEM_VIDEO: + video_item = ref_media_item.get("video_item") or {} + file_path = await self._download_media_item(video_item, "video") + if file_path: + content_parts.append(f"[video]\n[Video: source: {file_path}]") + media_paths.append(file_path) + content = "\n".join(content_parts) if not content: return diff --git a/tests/channels/test_weixin_channel.py b/tests/channels/test_weixin_channel.py index 076be610..565b08b0 100644 --- a/tests/channels/test_weixin_channel.py +++ b/tests/channels/test_weixin_channel.py @@ -176,6 +176,80 @@ async def test_process_message_extracts_media_and_preserves_paths() -> None: assert inbound.media == ["/tmp/test.jpg"] +@pytest.mark.asyncio +async def test_process_message_falls_back_to_referenced_media_when_no_top_level_media() -> None: + channel, bus = _make_channel() + channel._download_media_item = AsyncMock(return_value="/tmp/ref.jpg") + + await channel._process_message( + { + "message_type": 1, + "message_id": "m3-ref-fallback", + "from_user_id": "wx-user", + "context_token": "ctx-3-ref-fallback", + "item_list": [ + { + "type": ITEM_TEXT, + "text_item": {"text": "reply to image"}, + "ref_msg": { + "message_item": { + "type": ITEM_IMAGE, + "image_item": {"media": {"encrypt_query_param": "ref-enc"}}, + }, + }, + }, + ], + } + ) + + inbound = await asyncio.wait_for(bus.consume_inbound(), timeout=1.0) + + channel._download_media_item.assert_awaited_once_with( + {"media": {"encrypt_query_param": "ref-enc"}}, + "image", + ) + assert inbound.media == ["/tmp/ref.jpg"] + assert "reply to image" in inbound.content + assert "[image]" in inbound.content + + +@pytest.mark.asyncio +async def test_process_message_does_not_use_referenced_fallback_when_top_level_media_exists() -> None: + channel, bus = _make_channel() + channel._download_media_item = AsyncMock(side_effect=["/tmp/top.jpg", "/tmp/ref.jpg"]) + + await channel._process_message( + { + "message_type": 1, + "message_id": "m3-ref-no-fallback", + "from_user_id": "wx-user", + "context_token": "ctx-3-ref-no-fallback", + "item_list": [ + {"type": ITEM_IMAGE, "image_item": {"media": {"encrypt_query_param": "top-enc"}}}, + { + "type": ITEM_TEXT, + "text_item": {"text": "has top-level media"}, + "ref_msg": { + "message_item": { + "type": ITEM_IMAGE, + "image_item": {"media": {"encrypt_query_param": "ref-enc"}}, + }, + }, + }, + ], + } + ) + + inbound = await asyncio.wait_for(bus.consume_inbound(), timeout=1.0) + + channel._download_media_item.assert_awaited_once_with( + {"media": {"encrypt_query_param": "top-enc"}}, + "image", + ) + assert inbound.media == ["/tmp/top.jpg"] + assert "/tmp/ref.jpg" not in inbound.content + + @pytest.mark.asyncio async def test_send_without_context_token_does_not_send_text() -> None: channel, _bus = _make_channel() From 345c393e530dc0abb54409d3baace11227788bc0 Mon Sep 17 00:00:00 2001 From: xcosmosbox <2162381070@qq.com> Date: Sun, 29 Mar 2026 16:25:25 +0800 Subject: [PATCH 37/91] feat(weixin): implement getConfig and sendTyping --- nanobot/channels/weixin.py | 85 ++++++++++++++++++++++----- tests/channels/test_weixin_channel.py | 64 ++++++++++++++++++++ 2 files changed, 135 insertions(+), 14 deletions(-) diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py index 6324290f..eb7d218d 100644 --- a/nanobot/channels/weixin.py +++ b/nanobot/channels/weixin.py @@ -99,6 +99,9 @@ MAX_CONSECUTIVE_FAILURES = 3 BACKOFF_DELAY_S = 30 RETRY_DELAY_S = 2 MAX_QR_REFRESH_COUNT = 3 +TYPING_STATUS_TYPING = 1 +TYPING_STATUS_CANCEL = 2 +TYPING_TICKET_TTL_S = 24 * 60 * 60 # Default long-poll timeout; overridden by server via longpolling_timeout_ms. DEFAULT_LONG_POLL_TIMEOUT_S = 35 @@ -158,6 +161,7 @@ class WeixinChannel(BaseChannel): self._poll_task: asyncio.Task | None = None self._next_poll_timeout_s: int = DEFAULT_LONG_POLL_TIMEOUT_S self._session_pause_until: float = 0.0 + self._typing_tickets: dict[str, tuple[str, float]] = {} # ------------------------------------------------------------------ # State persistence @@ -832,6 +836,40 @@ class WeixinChannel(BaseChannel): # Outbound (matches send.ts buildTextMessageReq + sendMessageWeixin) # ------------------------------------------------------------------ + async def _get_typing_ticket(self, user_id: str, context_token: str = "") -> str: + """Get typing ticket for a user with simple per-user TTL cache.""" + now = time.time() + cached = self._typing_tickets.get(user_id) + if cached: + ticket, expires_at = cached + if ticket and now < expires_at: + return ticket + + body: dict[str, Any] = { + "ilink_user_id": user_id, + "context_token": context_token or None, + "base_info": BASE_INFO, + } + data = await self._api_post("ilink/bot/getconfig", body) + if data.get("ret", 0) == 0: + ticket = str(data.get("typing_ticket", "") or "") + if ticket: + self._typing_tickets[user_id] = (ticket, now + TYPING_TICKET_TTL_S) + return ticket + return "" + + async def _send_typing(self, user_id: str, typing_ticket: str, status: int) -> None: + """Best-effort sendtyping wrapper.""" + if not typing_ticket: + return + body: dict[str, Any] = { + "ilink_user_id": user_id, + "typing_ticket": typing_ticket, + "status": status, + "base_info": BASE_INFO, + } + await self._api_post("ilink/bot/sendtyping", body) + async def send(self, msg: OutboundMessage) -> None: if not self._client or not self._token: logger.warning("WeChat client not initialized or not authenticated") @@ -851,29 +889,48 @@ class WeixinChannel(BaseChannel): ) return - # --- Send media files first (following Telegram channel pattern) --- - for media_path in (msg.media or []): - try: - await self._send_media_file(msg.chat_id, media_path, ctx_token) - except Exception as e: - filename = Path(media_path).name - logger.error("Failed to send WeChat media {}: {}", media_path, e) - # Notify user about failure via text - await self._send_text( - msg.chat_id, f"[Failed to send: {filename}]", ctx_token, - ) + typing_ticket = "" + try: + typing_ticket = await self._get_typing_ticket(msg.chat_id, ctx_token) + except Exception as e: + logger.warning("WeChat getconfig failed for {}: {}", msg.chat_id, e) + typing_ticket = "" - # --- Send text content --- - if not content: - return + if typing_ticket: + try: + await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_TYPING) + except Exception as e: + logger.debug("WeChat sendtyping(start) failed for {}: {}", msg.chat_id, e) try: + # --- Send media files first (following Telegram channel pattern) --- + for media_path in (msg.media or []): + try: + await self._send_media_file(msg.chat_id, media_path, ctx_token) + except Exception as e: + filename = Path(media_path).name + logger.error("Failed to send WeChat media {}: {}", media_path, e) + # Notify user about failure via text + await self._send_text( + msg.chat_id, f"[Failed to send: {filename}]", ctx_token, + ) + + # --- Send text content --- + if not content: + return + chunks = split_message(content, WEIXIN_MAX_MESSAGE_LEN) for chunk in chunks: await self._send_text(msg.chat_id, chunk, ctx_token) except Exception as e: logger.error("Error sending WeChat message: {}", e) raise + finally: + if typing_ticket: + try: + await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_CANCEL) + except Exception as e: + logger.debug("WeChat sendtyping(cancel) failed for {}: {}", msg.chat_id, e) async def _send_text( self, diff --git a/tests/channels/test_weixin_channel.py b/tests/channels/test_weixin_channel.py index 565b08b0..64ea0b37 100644 --- a/tests/channels/test_weixin_channel.py +++ b/tests/channels/test_weixin_channel.py @@ -280,6 +280,70 @@ async def test_send_does_not_send_when_session_is_paused() -> None: channel._send_text.assert_not_awaited() +@pytest.mark.asyncio +async def test_get_typing_ticket_fetches_and_caches_per_user() -> None: + channel, _bus = _make_channel() + channel._client = object() + channel._token = "token" + channel._api_post = AsyncMock(return_value={"ret": 0, "typing_ticket": "ticket-1"}) + + first = await channel._get_typing_ticket("wx-user", "ctx-1") + second = await channel._get_typing_ticket("wx-user", "ctx-2") + + assert first == "ticket-1" + assert second == "ticket-1" + channel._api_post.assert_awaited_once_with( + "ilink/bot/getconfig", + {"ilink_user_id": "wx-user", "context_token": "ctx-1", "base_info": weixin_mod.BASE_INFO}, + ) + + +@pytest.mark.asyncio +async def test_send_uses_typing_start_and_cancel_when_ticket_available() -> None: + channel, _bus = _make_channel() + channel._client = object() + channel._token = "token" + channel._context_tokens["wx-user"] = "ctx-typing" + channel._send_text = AsyncMock() + channel._api_post = AsyncMock( + side_effect=[ + {"ret": 0, "typing_ticket": "ticket-typing"}, + {"ret": 0}, + {"ret": 0}, + ] + ) + + await channel.send( + type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})() + ) + + channel._send_text.assert_awaited_once_with("wx-user", "pong", "ctx-typing") + assert channel._api_post.await_count == 3 + assert channel._api_post.await_args_list[0].args[0] == "ilink/bot/getconfig" + assert channel._api_post.await_args_list[1].args[0] == "ilink/bot/sendtyping" + assert channel._api_post.await_args_list[1].args[1]["status"] == 1 + assert channel._api_post.await_args_list[2].args[0] == "ilink/bot/sendtyping" + assert channel._api_post.await_args_list[2].args[1]["status"] == 2 + + +@pytest.mark.asyncio +async def test_send_still_sends_text_when_typing_ticket_missing() -> None: + channel, _bus = _make_channel() + channel._client = object() + channel._token = "token" + channel._context_tokens["wx-user"] = "ctx-no-ticket" + channel._send_text = AsyncMock() + channel._api_post = AsyncMock(return_value={"ret": 1, "errmsg": "no config"}) + + await channel.send( + type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})() + ) + + channel._send_text.assert_awaited_once_with("wx-user", "pong", "ctx-no-ticket") + channel._api_post.assert_awaited_once() + assert channel._api_post.await_args_list[0].args[0] == "ilink/bot/getconfig" + + @pytest.mark.asyncio async def test_poll_once_pauses_session_on_expired_errcode() -> None: channel, _bus = _make_channel() From 0514233217e7d2bec1e6b7fa831421ab5ab7834f Mon Sep 17 00:00:00 2001 From: xcosmosbox <2162381070@qq.com> Date: Sun, 29 Mar 2026 20:27:23 +0800 Subject: [PATCH 38/91] fix(weixin): align full_url AES key handling and quoted media fallback logic with reference 1. Fix full_url path for non-image media to require AES key and skip download when missing, instead of persisting encrypted bytes as valid media. 2. Restrict quoted media fallback trigger to only when no top-level media item exists, not when top-level media download/decryption fails. --- nanobot/channels/weixin.py | 23 +++++++++- tests/channels/test_weixin_channel.py | 61 +++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py index eb7d218d..74d3a473 100644 --- a/nanobot/channels/weixin.py +++ b/nanobot/channels/weixin.py @@ -116,6 +116,12 @@ _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff", ".ico" _VIDEO_EXTS = {".mp4", ".avi", ".mov", ".mkv", ".webm", ".flv"} +def _has_downloadable_media_locator(media: dict[str, Any] | None) -> bool: + if not isinstance(media, dict): + return False + return bool(str(media.get("encrypt_query_param", "") or "") or str(media.get("full_url", "") or "").strip()) + + class WeixinConfig(Base): """Personal WeChat channel configuration.""" @@ -611,6 +617,7 @@ class WeixinChannel(BaseChannel): item_list: list[dict] = msg.get("item_list") or [] content_parts: list[str] = [] media_paths: list[str] = [] + has_top_level_downloadable_media = False for item in item_list: item_type = item.get("type", 0) @@ -647,6 +654,8 @@ class WeixinChannel(BaseChannel): elif item_type == ITEM_IMAGE: image_item = item.get("image_item") or {} + if _has_downloadable_media_locator(image_item.get("media")): + has_top_level_downloadable_media = True file_path = await self._download_media_item(image_item, "image") if file_path: content_parts.append(f"[image]\n[Image: source: {file_path}]") @@ -661,6 +670,8 @@ class WeixinChannel(BaseChannel): if voice_text: content_parts.append(f"[voice] {voice_text}") else: + if _has_downloadable_media_locator(voice_item.get("media")): + has_top_level_downloadable_media = True file_path = await self._download_media_item(voice_item, "voice") if file_path: transcription = await self.transcribe_audio(file_path) @@ -674,6 +685,8 @@ class WeixinChannel(BaseChannel): elif item_type == ITEM_FILE: file_item = item.get("file_item") or {} + if _has_downloadable_media_locator(file_item.get("media")): + has_top_level_downloadable_media = True file_name = file_item.get("file_name", "unknown") file_path = await self._download_media_item( file_item, @@ -688,6 +701,8 @@ class WeixinChannel(BaseChannel): elif item_type == ITEM_VIDEO: video_item = item.get("video_item") or {} + if _has_downloadable_media_locator(video_item.get("media")): + has_top_level_downloadable_media = True file_path = await self._download_media_item(video_item, "video") if file_path: content_parts.append(f"[video]\n[Video: source: {file_path}]") @@ -698,7 +713,7 @@ class WeixinChannel(BaseChannel): # Fallback: when no top-level media was downloaded, try quoted/referenced media. # This aligns with the reference plugin behavior that checks ref_msg.message_item # when main item_list has no downloadable media. - if not media_paths: + if not media_paths and not has_top_level_downloadable_media: ref_media_item: dict[str, Any] | None = None for item in item_list: if item.get("type", 0) != ITEM_TEXT: @@ -793,6 +808,12 @@ class WeixinChannel(BaseChannel): elif media_aes_key_b64: aes_key_b64 = media_aes_key_b64 + # Reference protocol behavior: VOICE/FILE/VIDEO require aes_key; + # only IMAGE may be downloaded as plain bytes when key is missing. + if media_type != "image" and not aes_key_b64: + logger.debug("Missing AES key for {} item, skip media download", media_type) + return None + # Prefer server-provided full_url, fallback to encrypted_query_param URL construction. if full_url: cdn_url = full_url diff --git a/tests/channels/test_weixin_channel.py b/tests/channels/test_weixin_channel.py index 64ea0b37..7701ad59 100644 --- a/tests/channels/test_weixin_channel.py +++ b/tests/channels/test_weixin_channel.py @@ -250,6 +250,46 @@ async def test_process_message_does_not_use_referenced_fallback_when_top_level_m assert "/tmp/ref.jpg" not in inbound.content +@pytest.mark.asyncio +async def test_process_message_does_not_fallback_when_top_level_media_exists_but_download_fails() -> None: + channel, bus = _make_channel() + # Top-level image download fails (None), referenced image would succeed if fallback were triggered. + channel._download_media_item = AsyncMock(side_effect=[None, "/tmp/ref.jpg"]) + + await channel._process_message( + { + "message_type": 1, + "message_id": "m3-ref-no-fallback-on-failure", + "from_user_id": "wx-user", + "context_token": "ctx-3-ref-no-fallback-on-failure", + "item_list": [ + {"type": ITEM_IMAGE, "image_item": {"media": {"encrypt_query_param": "top-enc"}}}, + { + "type": ITEM_TEXT, + "text_item": {"text": "quoted has media"}, + "ref_msg": { + "message_item": { + "type": ITEM_IMAGE, + "image_item": {"media": {"encrypt_query_param": "ref-enc"}}, + }, + }, + }, + ], + } + ) + + inbound = await asyncio.wait_for(bus.consume_inbound(), timeout=1.0) + + # Should only attempt top-level media item; reference fallback must not activate. + channel._download_media_item.assert_awaited_once_with( + {"media": {"encrypt_query_param": "top-enc"}}, + "image", + ) + assert inbound.media == [] + assert "[image]" in inbound.content + assert "/tmp/ref.jpg" not in inbound.content + + @pytest.mark.asyncio async def test_send_without_context_token_does_not_send_text() -> None: channel, _bus = _make_channel() @@ -613,3 +653,24 @@ async def test_download_media_item_falls_back_to_encrypt_query_param(tmp_path) - assert Path(saved_path).read_bytes() == b"fallback-bytes" called_url = channel._client.get.await_args_list[0].args[0] assert called_url.startswith(f"{channel.config.cdn_base_url}/download?encrypted_query_param=enc-fallback") + + +@pytest.mark.asyncio +async def test_download_media_item_non_image_requires_aes_key_even_with_full_url(tmp_path) -> None: + channel, _bus = _make_channel() + weixin_mod.get_media_dir = lambda _name: tmp_path + + full_url = "https://cdn.example.test/download/voice" + channel._client = SimpleNamespace( + get=AsyncMock(return_value=_DummyDownloadResponse(content=b"ciphertext-or-unknown")) + ) + + item = { + "media": { + "full_url": full_url, + }, + } + saved_path = await channel._download_media_item(item, "voice") + + assert saved_path is None + channel._client.get.assert_not_awaited() From 26947db47996c0e02cc869b27f243873298f2818 Mon Sep 17 00:00:00 2001 From: xcosmosbox <2162381070@qq.com> Date: Sun, 29 Mar 2026 21:28:58 +0800 Subject: [PATCH 39/91] feat(weixin): add voice message, typing keepalive, getConfig cache, and QR polling resilience --- nanobot/channels/weixin.py | 94 ++++++++++++++-- tests/channels/test_weixin_channel.py | 153 ++++++++++++++++++++++++++ 2 files changed, 235 insertions(+), 12 deletions(-) diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py index 74d3a473..4341f21d 100644 --- a/nanobot/channels/weixin.py +++ b/nanobot/channels/weixin.py @@ -15,6 +15,7 @@ import hashlib import json import mimetypes import os +import random import re import time import uuid @@ -102,18 +103,23 @@ MAX_QR_REFRESH_COUNT = 3 TYPING_STATUS_TYPING = 1 TYPING_STATUS_CANCEL = 2 TYPING_TICKET_TTL_S = 24 * 60 * 60 +TYPING_KEEPALIVE_INTERVAL_S = 5 +CONFIG_CACHE_INITIAL_RETRY_S = 2 +CONFIG_CACHE_MAX_RETRY_S = 60 * 60 # Default long-poll timeout; overridden by server via longpolling_timeout_ms. DEFAULT_LONG_POLL_TIMEOUT_S = 35 -# Media-type codes for getuploadurl (1=image, 2=video, 3=file) +# Media-type codes for getuploadurl (1=image, 2=video, 3=file, 4=voice) UPLOAD_MEDIA_IMAGE = 1 UPLOAD_MEDIA_VIDEO = 2 UPLOAD_MEDIA_FILE = 3 +UPLOAD_MEDIA_VOICE = 4 # File extensions considered as images / videos for outbound media _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff", ".ico", ".svg"} _VIDEO_EXTS = {".mp4", ".avi", ".mov", ".mkv", ".webm", ".flv"} +_VOICE_EXTS = {".mp3", ".wav", ".amr", ".silk", ".ogg", ".m4a", ".aac", ".flac"} def _has_downloadable_media_locator(media: dict[str, Any] | None) -> bool: @@ -167,7 +173,7 @@ class WeixinChannel(BaseChannel): self._poll_task: asyncio.Task | None = None self._next_poll_timeout_s: int = DEFAULT_LONG_POLL_TIMEOUT_S self._session_pause_until: float = 0.0 - self._typing_tickets: dict[str, tuple[str, float]] = {} + self._typing_tickets: dict[str, dict[str, Any]] = {} # ------------------------------------------------------------------ # State persistence @@ -339,7 +345,16 @@ class WeixinChannel(BaseChannel): params={"qrcode": qrcode_id}, auth=False, ) - except httpx.TimeoutException: + except Exception as e: + if self._is_retryable_qr_poll_error(e): + logger.warning("QR polling temporary error, will retry: {}", e) + await asyncio.sleep(1) + continue + raise + + if not isinstance(status_data, dict): + logger.warning("QR polling got non-object response, continue waiting") + await asyncio.sleep(1) continue status = status_data.get("status", "") @@ -408,6 +423,16 @@ class WeixinChannel(BaseChannel): return False + @staticmethod + def _is_retryable_qr_poll_error(err: Exception) -> bool: + if isinstance(err, httpx.TimeoutException | httpx.TransportError): + return True + if isinstance(err, httpx.HTTPStatusError): + status_code = err.response.status_code if err.response is not None else 0 + if status_code >= 500: + return True + return False + @staticmethod def _print_qr_code(url: str) -> None: try: @@ -858,13 +883,11 @@ class WeixinChannel(BaseChannel): # ------------------------------------------------------------------ async def _get_typing_ticket(self, user_id: str, context_token: str = "") -> str: - """Get typing ticket for a user with simple per-user TTL cache.""" + """Get typing ticket with per-user refresh + failure backoff cache.""" now = time.time() - cached = self._typing_tickets.get(user_id) - if cached: - ticket, expires_at = cached - if ticket and now < expires_at: - return ticket + entry = self._typing_tickets.get(user_id) + if entry and now < float(entry.get("next_fetch_at", 0)): + return str(entry.get("ticket", "") or "") body: dict[str, Any] = { "ilink_user_id": user_id, @@ -874,9 +897,27 @@ class WeixinChannel(BaseChannel): data = await self._api_post("ilink/bot/getconfig", body) if data.get("ret", 0) == 0: ticket = str(data.get("typing_ticket", "") or "") - if ticket: - self._typing_tickets[user_id] = (ticket, now + TYPING_TICKET_TTL_S) - return ticket + self._typing_tickets[user_id] = { + "ticket": ticket, + "ever_succeeded": True, + "next_fetch_at": now + (random.random() * TYPING_TICKET_TTL_S), + "retry_delay_s": CONFIG_CACHE_INITIAL_RETRY_S, + } + return ticket + + prev_delay = float(entry.get("retry_delay_s", CONFIG_CACHE_INITIAL_RETRY_S)) if entry else CONFIG_CACHE_INITIAL_RETRY_S + next_delay = min(prev_delay * 2, CONFIG_CACHE_MAX_RETRY_S) + if entry: + entry["next_fetch_at"] = now + next_delay + entry["retry_delay_s"] = next_delay + return str(entry.get("ticket", "") or "") + + self._typing_tickets[user_id] = { + "ticket": "", + "ever_succeeded": False, + "next_fetch_at": now + CONFIG_CACHE_INITIAL_RETRY_S, + "retry_delay_s": CONFIG_CACHE_INITIAL_RETRY_S, + } return "" async def _send_typing(self, user_id: str, typing_ticket: str, status: int) -> None: @@ -891,6 +932,16 @@ class WeixinChannel(BaseChannel): } await self._api_post("ilink/bot/sendtyping", body) + async def _typing_keepalive_loop(self, user_id: str, typing_ticket: str, stop_event: asyncio.Event) -> None: + while not stop_event.is_set(): + await asyncio.sleep(TYPING_KEEPALIVE_INTERVAL_S) + if stop_event.is_set(): + break + try: + await self._send_typing(user_id, typing_ticket, TYPING_STATUS_TYPING) + except Exception as e: + logger.debug("WeChat sendtyping(keepalive) failed for {}: {}", user_id, e) + async def send(self, msg: OutboundMessage) -> None: if not self._client or not self._token: logger.warning("WeChat client not initialized or not authenticated") @@ -923,6 +974,13 @@ class WeixinChannel(BaseChannel): except Exception as e: logger.debug("WeChat sendtyping(start) failed for {}: {}", msg.chat_id, e) + typing_keepalive_stop = asyncio.Event() + typing_keepalive_task: asyncio.Task | None = None + if typing_ticket: + typing_keepalive_task = asyncio.create_task( + self._typing_keepalive_loop(msg.chat_id, typing_ticket, typing_keepalive_stop) + ) + try: # --- Send media files first (following Telegram channel pattern) --- for media_path in (msg.media or []): @@ -947,6 +1005,14 @@ class WeixinChannel(BaseChannel): logger.error("Error sending WeChat message: {}", e) raise finally: + if typing_keepalive_task: + typing_keepalive_stop.set() + typing_keepalive_task.cancel() + try: + await typing_keepalive_task + except asyncio.CancelledError: + pass + if typing_ticket: try: await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_CANCEL) @@ -1025,6 +1091,10 @@ class WeixinChannel(BaseChannel): upload_type = UPLOAD_MEDIA_VIDEO item_type = ITEM_VIDEO item_key = "video_item" + elif ext in _VOICE_EXTS: + upload_type = UPLOAD_MEDIA_VOICE + item_type = ITEM_VOICE + item_key = "voice_item" else: upload_type = UPLOAD_MEDIA_FILE item_type = ITEM_FILE diff --git a/tests/channels/test_weixin_channel.py b/tests/channels/test_weixin_channel.py index 7701ad59..c4e5cf55 100644 --- a/tests/channels/test_weixin_channel.py +++ b/tests/channels/test_weixin_channel.py @@ -6,6 +6,7 @@ from types import SimpleNamespace from unittest.mock import AsyncMock import pytest +import httpx import nanobot.channels.weixin as weixin_mod from nanobot.bus.queue import MessageBus @@ -595,6 +596,158 @@ async def test_send_media_falls_back_to_upload_param_url(tmp_path) -> None: assert "&filekey=" in cdn_url +@pytest.mark.asyncio +async def test_send_media_voice_file_uses_voice_item_and_voice_upload_type(tmp_path) -> None: + channel, _bus = _make_channel() + + media_file = tmp_path / "voice.mp3" + media_file.write_bytes(b"voice-bytes") + + cdn_post = AsyncMock(return_value=_DummyHttpResponse(headers={"x-encrypted-param": "voice-dl-param"})) + channel._client = SimpleNamespace(post=cdn_post) + channel._api_post = AsyncMock( + side_effect=[ + {"upload_full_url": "https://upload-full.example.test/voice?foo=bar"}, + {"ret": 0}, + ] + ) + + await channel._send_media_file("wx-user", str(media_file), "ctx-voice") + + getupload_body = channel._api_post.await_args_list[0].args[1] + assert getupload_body["media_type"] == 4 + + sendmessage_body = channel._api_post.await_args_list[1].args[1] + item = sendmessage_body["msg"]["item_list"][0] + assert item["type"] == 3 + assert "voice_item" in item + assert "file_item" not in item + assert item["voice_item"]["media"]["encrypt_query_param"] == "voice-dl-param" + + +@pytest.mark.asyncio +async def test_send_typing_uses_keepalive_until_send_finishes() -> None: + channel, _bus = _make_channel() + channel._client = object() + channel._token = "token" + channel._context_tokens["wx-user"] = "ctx-typing-loop" + async def _api_post_side_effect(endpoint: str, _body: dict | None = None, *, auth: bool = True): + if endpoint == "ilink/bot/getconfig": + return {"ret": 0, "typing_ticket": "ticket-keepalive"} + return {"ret": 0} + + channel._api_post = AsyncMock(side_effect=_api_post_side_effect) + + async def _slow_send_text(*_args, **_kwargs) -> None: + await asyncio.sleep(0.03) + + channel._send_text = AsyncMock(side_effect=_slow_send_text) + + old_interval = weixin_mod.TYPING_KEEPALIVE_INTERVAL_S + weixin_mod.TYPING_KEEPALIVE_INTERVAL_S = 0.01 + try: + await channel.send( + type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})() + ) + finally: + weixin_mod.TYPING_KEEPALIVE_INTERVAL_S = old_interval + + status_calls = [ + c.args[1]["status"] + for c in channel._api_post.await_args_list + if c.args and c.args[0] == "ilink/bot/sendtyping" + ] + assert status_calls.count(1) >= 2 + assert status_calls[-1] == 2 + + +@pytest.mark.asyncio +async def test_get_typing_ticket_failure_uses_backoff_and_cached_ticket(monkeypatch) -> None: + channel, _bus = _make_channel() + channel._client = object() + channel._token = "token" + + now = {"value": 1000.0} + monkeypatch.setattr(weixin_mod.time, "time", lambda: now["value"]) + monkeypatch.setattr(weixin_mod.random, "random", lambda: 0.5) + + channel._api_post = AsyncMock(return_value={"ret": 0, "typing_ticket": "ticket-ok"}) + first = await channel._get_typing_ticket("wx-user", "ctx-1") + assert first == "ticket-ok" + + # force refresh window reached + now["value"] = now["value"] + (12 * 60 * 60) + 1 + channel._api_post = AsyncMock(return_value={"ret": 1, "errmsg": "temporary failure"}) + + # On refresh failure, should still return cached ticket and apply backoff. + second = await channel._get_typing_ticket("wx-user", "ctx-2") + assert second == "ticket-ok" + assert channel._api_post.await_count == 1 + + # Before backoff expiry, no extra fetch should happen. + now["value"] += 1 + third = await channel._get_typing_ticket("wx-user", "ctx-3") + assert third == "ticket-ok" + assert channel._api_post.await_count == 1 + + +@pytest.mark.asyncio +async def test_qr_login_treats_temporary_connect_error_as_wait_and_recovers() -> None: + channel, _bus = _make_channel() + channel._running = True + channel._save_state = lambda: None + channel._print_qr_code = lambda url: None + channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1")) + + request = httpx.Request("GET", "https://ilinkai.weixin.qq.com/ilink/bot/get_qrcode_status") + channel._api_get_with_base = AsyncMock( + side_effect=[ + httpx.ConnectError("temporary network", request=request), + { + "status": "confirmed", + "bot_token": "token-net-ok", + "ilink_bot_id": "bot-id", + "baseurl": "https://example.test", + "ilink_user_id": "wx-user", + }, + ] + ) + + ok = await channel._qr_login() + + assert ok is True + assert channel._token == "token-net-ok" + + +@pytest.mark.asyncio +async def test_qr_login_treats_5xx_gateway_response_error_as_wait_and_recovers() -> None: + channel, _bus = _make_channel() + channel._running = True + channel._save_state = lambda: None + channel._print_qr_code = lambda url: None + channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1")) + + request = httpx.Request("GET", "https://ilinkai.weixin.qq.com/ilink/bot/get_qrcode_status") + response = httpx.Response(status_code=524, request=request) + channel._api_get_with_base = AsyncMock( + side_effect=[ + httpx.HTTPStatusError("gateway timeout", request=request, response=response), + { + "status": "confirmed", + "bot_token": "token-5xx-ok", + "ilink_bot_id": "bot-id", + "baseurl": "https://example.test", + "ilink_user_id": "wx-user", + }, + ] + ) + + ok = await channel._qr_login() + + assert ok is True + assert channel._token == "token-5xx-ok" + + def test_decrypt_aes_ecb_strips_valid_pkcs7_padding() -> None: key_b64 = "MDEyMzQ1Njc4OWFiY2RlZg==" # base64("0123456789abcdef") plaintext = b"hello-weixin-padding" From 1bcd5f97428f3136bf337972caaf719b334fc92d Mon Sep 17 00:00:00 2001 From: xcosmosbox <2162381070@qq.com> Date: Mon, 30 Mar 2026 09:06:49 +0800 Subject: [PATCH 40/91] fix(weixin): fix test file version reader --- nanobot/channels/weixin.py | 21 +++------------------ tests/channels/test_weixin_channel.py | 3 +-- 2 files changed, 4 insertions(+), 20 deletions(-) diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py index 4341f21d..7f6c6aba 100644 --- a/nanobot/channels/weixin.py +++ b/nanobot/channels/weixin.py @@ -54,19 +54,8 @@ MESSAGE_TYPE_BOT = 2 MESSAGE_STATE_FINISH = 2 WEIXIN_MAX_MESSAGE_LEN = 4000 - - -def _read_reference_package_meta() -> dict[str, str]: - """Best-effort read of reference `package/package.json` metadata.""" - try: - pkg_path = Path(__file__).resolve().parents[2] / "package" / "package.json" - data = json.loads(pkg_path.read_text(encoding="utf-8")) - return { - "version": str(data.get("version", "") or ""), - "ilink_appid": str(data.get("ilink_appid", "") or ""), - } - except Exception: - return {"version": "", "ilink_appid": ""} +WEIXIN_CHANNEL_VERSION = "2.1.1" +ILINK_APP_ID = "bot" def _build_client_version(version: str) -> int: @@ -84,11 +73,7 @@ def _build_client_version(version: str) -> int: patch = _as_int(2) return ((major & 0xFF) << 16) | ((minor & 0xFF) << 8) | (patch & 0xFF) - -_PKG_META = _read_reference_package_meta() -WEIXIN_CHANNEL_VERSION = _PKG_META["version"] or "unknown" -ILINK_APP_ID = _PKG_META["ilink_appid"] -ILINK_APP_CLIENT_VERSION = _build_client_version(_PKG_META["version"] or "0.0.0") +ILINK_APP_CLIENT_VERSION = _build_client_version(WEIXIN_CHANNEL_VERSION) BASE_INFO: dict[str, str] = {"channel_version": WEIXIN_CHANNEL_VERSION} # Session-expired error code diff --git a/tests/channels/test_weixin_channel.py b/tests/channels/test_weixin_channel.py index c4e5cf55..f4d57a8b 100644 --- a/tests/channels/test_weixin_channel.py +++ b/tests/channels/test_weixin_channel.py @@ -52,8 +52,7 @@ def test_make_headers_includes_route_tag_when_configured() -> None: def test_channel_version_matches_reference_plugin_version() -> None: - pkg = json.loads(Path("package/package.json").read_text()) - assert WEIXIN_CHANNEL_VERSION == pkg["version"] + assert WEIXIN_CHANNEL_VERSION == "2.1.1" def test_save_and_load_state_persists_context_tokens(tmp_path) -> None: From 2a6c616080d5e8bc5b053cb2f629daefef5fa775 Mon Sep 17 00:00:00 2001 From: xcosmosbox <2162381070@qq.com> Date: Tue, 31 Mar 2026 12:55:29 +0800 Subject: [PATCH 41/91] fix(WeiXin): fix full_url download error --- nanobot/channels/weixin.py | 142 ++++++++++++-------------- tests/channels/test_weixin_channel.py | 63 ++++++++++++ 2 files changed, 126 insertions(+), 79 deletions(-) diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py index 7f6c6aba..c6c1603a 100644 --- a/nanobot/channels/weixin.py +++ b/nanobot/channels/weixin.py @@ -197,8 +197,7 @@ class WeixinChannel(BaseChannel): if base_url: self.config.base_url = base_url return bool(self._token) - except Exception as e: - logger.warning("Failed to load WeChat state: {}", e) + except Exception: return False def _save_state(self) -> None: @@ -211,8 +210,8 @@ class WeixinChannel(BaseChannel): "base_url": self.config.base_url, } state_file.write_text(json.dumps(data, ensure_ascii=False)) - except Exception as e: - logger.warning("Failed to save WeChat state: {}", e) + except Exception: + pass # ------------------------------------------------------------------ # HTTP helpers (matches api.ts buildHeaders / apiFetch) @@ -243,6 +242,15 @@ class WeixinChannel(BaseChannel): headers["SKRouteTag"] = str(self.config.route_tag).strip() return headers + @staticmethod + def _is_retryable_media_download_error(err: Exception) -> bool: + if isinstance(err, httpx.TimeoutException | httpx.TransportError): + return True + if isinstance(err, httpx.HTTPStatusError): + status_code = err.response.status_code if err.response is not None else 0 + return status_code >= 500 + return False + async def _api_get( self, endpoint: str, @@ -315,13 +323,11 @@ class WeixinChannel(BaseChannel): async def _qr_login(self) -> bool: """Perform QR code login flow. Returns True on success.""" try: - logger.info("Starting WeChat QR code login...") refresh_count = 0 qrcode_id, scan_url = await self._fetch_qr_code() self._print_qr_code(scan_url) current_poll_base_url = self.config.base_url - logger.info("Waiting for QR code scan...") while self._running: try: status_data = await self._api_get_with_base( @@ -332,13 +338,11 @@ class WeixinChannel(BaseChannel): ) except Exception as e: if self._is_retryable_qr_poll_error(e): - logger.warning("QR polling temporary error, will retry: {}", e) await asyncio.sleep(1) continue raise if not isinstance(status_data, dict): - logger.warning("QR polling got non-object response, continue waiting") await asyncio.sleep(1) continue @@ -362,8 +366,6 @@ class WeixinChannel(BaseChannel): else: logger.error("Login confirmed but no bot_token in response") return False - elif status == "scaned": - logger.info("QR code scanned, waiting for confirmation...") elif status == "scaned_but_redirect": redirect_host = str(status_data.get("redirect_host", "") or "").strip() if redirect_host: @@ -372,15 +374,7 @@ class WeixinChannel(BaseChannel): else: redirected_base = f"https://{redirect_host}" if redirected_base != current_poll_base_url: - logger.info( - "QR status redirect: switching polling host to {}", - redirected_base, - ) current_poll_base_url = redirected_base - else: - logger.warning( - "QR status returned scaned_but_redirect but redirect_host is missing", - ) elif status == "expired": refresh_count += 1 if refresh_count > MAX_QR_REFRESH_COUNT: @@ -390,14 +384,8 @@ class WeixinChannel(BaseChannel): MAX_QR_REFRESH_COUNT, ) return False - logger.warning( - "QR code expired, refreshing... ({}/{})", - refresh_count, - MAX_QR_REFRESH_COUNT, - ) qrcode_id, scan_url = await self._fetch_qr_code() self._print_qr_code(scan_url) - logger.info("New QR code generated, waiting for scan...") continue # status == "wait" — keep polling @@ -428,7 +416,6 @@ class WeixinChannel(BaseChannel): qr.make(fit=True) qr.print_ascii(invert=True) except ImportError: - logger.info("QR code URL (install 'qrcode' for terminal display): {}", url) print(f"\nLogin URL: {url}\n") # ------------------------------------------------------------------ @@ -490,12 +477,6 @@ class WeixinChannel(BaseChannel): if not self._running: break consecutive_failures += 1 - logger.error( - "WeChat poll error ({}/{}): {}", - consecutive_failures, - MAX_CONSECUTIVE_FAILURES, - e, - ) if consecutive_failures >= MAX_CONSECUTIVE_FAILURES: consecutive_failures = 0 await asyncio.sleep(BACKOFF_DELAY_S) @@ -510,8 +491,6 @@ class WeixinChannel(BaseChannel): await self._client.aclose() self._client = None self._save_state() - logger.info("WeChat channel stopped") - # ------------------------------------------------------------------ # Polling (matches monitor.ts monitorWeixinProvider) # ------------------------------------------------------------------ @@ -537,10 +516,6 @@ class WeixinChannel(BaseChannel): async def _poll_once(self) -> None: remaining = self._session_pause_remaining_s() if remaining > 0: - logger.warning( - "WeChat session paused, waiting {} min before next poll.", - max((remaining + 59) // 60, 1), - ) await asyncio.sleep(remaining) return @@ -590,8 +565,8 @@ class WeixinChannel(BaseChannel): for msg in msgs: try: await self._process_message(msg) - except Exception as e: - logger.error("Error processing WeChat message: {}", e) + except Exception: + pass # ------------------------------------------------------------------ # Inbound message processing (matches inbound.ts + process-message.ts) @@ -770,13 +745,6 @@ class WeixinChannel(BaseChannel): if not content: return - logger.info( - "WeChat inbound: from={} items={} bodyLen={}", - from_user_id, - ",".join(str(i.get("type", 0)) for i in item_list), - len(content), - ) - await self._handle_message( sender_id=from_user_id, chat_id=from_user_id, @@ -821,27 +789,47 @@ class WeixinChannel(BaseChannel): # Reference protocol behavior: VOICE/FILE/VIDEO require aes_key; # only IMAGE may be downloaded as plain bytes when key is missing. if media_type != "image" and not aes_key_b64: - logger.debug("Missing AES key for {} item, skip media download", media_type) return None - # Prefer server-provided full_url, fallback to encrypted_query_param URL construction. - if full_url: - cdn_url = full_url - else: - cdn_url = ( + assert self._client is not None + fallback_url = "" + if encrypt_query_param: + fallback_url = ( f"{self.config.cdn_base_url}/download" f"?encrypted_query_param={quote(encrypt_query_param)}" ) - assert self._client is not None - resp = await self._client.get(cdn_url) - resp.raise_for_status() - data = resp.content + download_candidates: list[tuple[str, str]] = [] + if full_url: + download_candidates.append(("full_url", full_url)) + if fallback_url and (not full_url or fallback_url != full_url): + download_candidates.append(("encrypt_query_param", fallback_url)) + + data = b"" + for idx, (download_source, cdn_url) in enumerate(download_candidates): + try: + resp = await self._client.get(cdn_url) + resp.raise_for_status() + data = resp.content + break + except Exception as e: + has_more_candidates = idx + 1 < len(download_candidates) + should_fallback = ( + download_source == "full_url" + and has_more_candidates + and self._is_retryable_media_download_error(e) + ) + if should_fallback: + logger.warning( + "WeChat media download failed via full_url, falling back to encrypt_query_param: type={} err={}", + media_type, + e, + ) + continue + raise if aes_key_b64 and data: data = _decrypt_aes_ecb(data, aes_key_b64) - elif not aes_key_b64: - logger.debug("No AES key for {} item, using raw bytes", media_type) if not data: return None @@ -856,7 +844,6 @@ class WeixinChannel(BaseChannel): safe_name = os.path.basename(filename) file_path = media_dir / safe_name file_path.write_bytes(data) - logger.debug("Downloaded WeChat {} to {}", media_type, file_path) return str(file_path) except Exception as e: @@ -918,14 +905,17 @@ class WeixinChannel(BaseChannel): await self._api_post("ilink/bot/sendtyping", body) async def _typing_keepalive_loop(self, user_id: str, typing_ticket: str, stop_event: asyncio.Event) -> None: - while not stop_event.is_set(): - await asyncio.sleep(TYPING_KEEPALIVE_INTERVAL_S) - if stop_event.is_set(): - break - try: - await self._send_typing(user_id, typing_ticket, TYPING_STATUS_TYPING) - except Exception as e: - logger.debug("WeChat sendtyping(keepalive) failed for {}: {}", user_id, e) + try: + while not stop_event.is_set(): + await asyncio.sleep(TYPING_KEEPALIVE_INTERVAL_S) + if stop_event.is_set(): + break + try: + await self._send_typing(user_id, typing_ticket, TYPING_STATUS_TYPING) + except Exception: + pass + finally: + pass async def send(self, msg: OutboundMessage) -> None: if not self._client or not self._token: @@ -933,8 +923,7 @@ class WeixinChannel(BaseChannel): return try: self._assert_session_active() - except RuntimeError as e: - logger.warning("WeChat send blocked: {}", e) + except RuntimeError: return content = msg.content.strip() @@ -949,15 +938,14 @@ class WeixinChannel(BaseChannel): typing_ticket = "" try: typing_ticket = await self._get_typing_ticket(msg.chat_id, ctx_token) - except Exception as e: - logger.warning("WeChat getconfig failed for {}: {}", msg.chat_id, e) + except Exception: typing_ticket = "" if typing_ticket: try: await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_TYPING) - except Exception as e: - logger.debug("WeChat sendtyping(start) failed for {}: {}", msg.chat_id, e) + except Exception: + pass typing_keepalive_stop = asyncio.Event() typing_keepalive_task: asyncio.Task | None = None @@ -1001,8 +989,8 @@ class WeixinChannel(BaseChannel): if typing_ticket: try: await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_CANCEL) - except Exception as e: - logger.debug("WeChat sendtyping(cancel) failed for {}: {}", msg.chat_id, e) + except Exception: + pass async def _send_text( self, @@ -1108,7 +1096,6 @@ class WeixinChannel(BaseChannel): assert self._client is not None upload_resp = await self._api_post("ilink/bot/getuploadurl", upload_body) - logger.debug("WeChat getuploadurl response: {}", upload_resp) upload_full_url = str(upload_resp.get("upload_full_url", "") or "").strip() upload_param = str(upload_resp.get("upload_param", "") or "") @@ -1130,7 +1117,6 @@ class WeixinChannel(BaseChannel): f"?encrypted_query_param={quote(upload_param)}" f"&filekey={quote(file_key)}" ) - logger.debug("WeChat CDN POST url={} ciphertextSize={}", cdn_upload_url[:80], len(encrypted_data)) cdn_resp = await self._client.post( cdn_upload_url, @@ -1146,7 +1132,6 @@ class WeixinChannel(BaseChannel): "CDN upload response missing x-encrypted-param header; " f"status={cdn_resp.status_code} headers={dict(cdn_resp.headers)}" ) - logger.debug("WeChat CDN upload success for {}, got download_param", p.name) # Step 3: Send message with the media item # aes_key for CDNMedia is the hex key encoded as base64 @@ -1195,7 +1180,6 @@ class WeixinChannel(BaseChannel): raise RuntimeError( f"WeChat send media error (code {errcode}): {data.get('errmsg', '')}" ) - logger.info("WeChat media sent: {} (type={})", p.name, item_key) # --------------------------------------------------------------------------- diff --git a/tests/channels/test_weixin_channel.py b/tests/channels/test_weixin_channel.py index f4d57a8b..515eaa28 100644 --- a/tests/channels/test_weixin_channel.py +++ b/tests/channels/test_weixin_channel.py @@ -766,6 +766,21 @@ class _DummyDownloadResponse: return None +class _DummyErrorDownloadResponse(_DummyDownloadResponse): + def __init__(self, url: str, status_code: int) -> None: + super().__init__(content=b"", status_code=status_code) + self._url = url + + def raise_for_status(self) -> None: + request = httpx.Request("GET", self._url) + response = httpx.Response(self.status_code, request=request) + raise httpx.HTTPStatusError( + f"download failed with status {self.status_code}", + request=request, + response=response, + ) + + @pytest.mark.asyncio async def test_download_media_item_uses_full_url_when_present(tmp_path) -> None: channel, _bus = _make_channel() @@ -789,6 +804,37 @@ async def test_download_media_item_uses_full_url_when_present(tmp_path) -> None: channel._client.get.assert_awaited_once_with(full_url) +@pytest.mark.asyncio +async def test_download_media_item_falls_back_when_full_url_returns_retryable_error(tmp_path) -> None: + channel, _bus = _make_channel() + weixin_mod.get_media_dir = lambda _name: tmp_path + + full_url = "https://cdn.example.test/download/full?taskid=123" + channel._client = SimpleNamespace( + get=AsyncMock( + side_effect=[ + _DummyErrorDownloadResponse(full_url, 500), + _DummyDownloadResponse(content=b"fallback-bytes"), + ] + ) + ) + + item = { + "media": { + "full_url": full_url, + "encrypt_query_param": "enc-fallback", + }, + } + saved_path = await channel._download_media_item(item, "image") + + assert saved_path is not None + assert Path(saved_path).read_bytes() == b"fallback-bytes" + assert channel._client.get.await_count == 2 + assert channel._client.get.await_args_list[0].args[0] == full_url + fallback_url = channel._client.get.await_args_list[1].args[0] + assert fallback_url.startswith(f"{channel.config.cdn_base_url}/download?encrypted_query_param=enc-fallback") + + @pytest.mark.asyncio async def test_download_media_item_falls_back_to_encrypt_query_param(tmp_path) -> None: channel, _bus = _make_channel() @@ -807,6 +853,23 @@ async def test_download_media_item_falls_back_to_encrypt_query_param(tmp_path) - assert called_url.startswith(f"{channel.config.cdn_base_url}/download?encrypted_query_param=enc-fallback") +@pytest.mark.asyncio +async def test_download_media_item_does_not_retry_when_full_url_fails_without_fallback(tmp_path) -> None: + channel, _bus = _make_channel() + weixin_mod.get_media_dir = lambda _name: tmp_path + + full_url = "https://cdn.example.test/download/full" + channel._client = SimpleNamespace( + get=AsyncMock(return_value=_DummyErrorDownloadResponse(full_url, 500)) + ) + + item = {"media": {"full_url": full_url}} + saved_path = await channel._download_media_item(item, "image") + + assert saved_path is None + channel._client.get.assert_awaited_once_with(full_url) + + @pytest.mark.asyncio async def test_download_media_item_non_image_requires_aes_key_even_with_full_url(tmp_path) -> None: channel, _bus = _make_channel() From 949a10f536c6a65c16e1108aa363c563b60f0a27 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Tue, 31 Mar 2026 11:34:33 +0000 Subject: [PATCH 42/91] fix(weixin): reset QR poll host after refresh --- nanobot/channels/weixin.py | 1 + tests/channels/test_weixin_channel.py | 35 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py index c6c1603a..891cfd09 100644 --- a/nanobot/channels/weixin.py +++ b/nanobot/channels/weixin.py @@ -385,6 +385,7 @@ class WeixinChannel(BaseChannel): ) return False qrcode_id, scan_url = await self._fetch_qr_code() + current_poll_base_url = self.config.base_url self._print_qr_code(scan_url) continue # status == "wait" — keep polling diff --git a/tests/channels/test_weixin_channel.py b/tests/channels/test_weixin_channel.py index 515eaa28..58fc3086 100644 --- a/tests/channels/test_weixin_channel.py +++ b/tests/channels/test_weixin_channel.py @@ -519,6 +519,41 @@ async def test_qr_login_redirect_without_host_keeps_current_polling_base_url() - assert second_call.kwargs["base_url"] == "https://ilinkai.weixin.qq.com" +@pytest.mark.asyncio +async def test_qr_login_resets_redirect_base_url_after_qr_refresh() -> None: + channel, _bus = _make_channel() + channel._running = True + channel._save_state = lambda: None + channel._print_qr_code = lambda url: None + channel._fetch_qr_code = AsyncMock(side_effect=[("qr-1", "url-1"), ("qr-2", "url-2")]) + + channel._api_get_with_base = AsyncMock( + side_effect=[ + {"status": "scaned_but_redirect", "redirect_host": "idc.redirect.test"}, + {"status": "expired"}, + { + "status": "confirmed", + "bot_token": "token-5", + "ilink_bot_id": "bot-5", + "baseurl": "https://example.test", + "ilink_user_id": "wx-user", + }, + ] + ) + + ok = await channel._qr_login() + + assert ok is True + assert channel._token == "token-5" + assert channel._api_get_with_base.await_count == 3 + first_call = channel._api_get_with_base.await_args_list[0] + second_call = channel._api_get_with_base.await_args_list[1] + third_call = channel._api_get_with_base.await_args_list[2] + assert first_call.kwargs["base_url"] == "https://ilinkai.weixin.qq.com" + assert second_call.kwargs["base_url"] == "https://idc.redirect.test" + assert third_call.kwargs["base_url"] == "https://ilinkai.weixin.qq.com" + + @pytest.mark.asyncio async def test_process_message_skips_bot_messages() -> None: channel, bus = _make_channel() From 69624779dcd383e7c83e58460ca2f5473632fa52 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Tue, 31 Mar 2026 21:45:42 +0800 Subject: [PATCH 43/91] fix(test): fix flaky test_fixed_session_requests_are_serialized Remove the fragile barrier-based synchronization that could cause deadlock when the second request is scheduled first. Instead, rely on the session lock for serialization and handle either execution order in assertions. --- tests/test_openai_api.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/tests/test_openai_api.py b/tests/test_openai_api.py index 3d29d476..42fec33e 100644 --- a/tests/test_openai_api.py +++ b/tests/test_openai_api.py @@ -235,15 +235,10 @@ async def test_followup_requests_share_same_session_key(aiohttp_client) -> None: @pytest.mark.asyncio async def test_fixed_session_requests_are_serialized(aiohttp_client) -> None: order: list[str] = [] - barrier = asyncio.Event() async def slow_process(content, session_key="", channel="", chat_id=""): order.append(f"start:{content}") - if content == "first": - barrier.set() - await asyncio.sleep(0.1) - else: - await barrier.wait() + await asyncio.sleep(0.1) order.append(f"end:{content}") return content @@ -264,7 +259,11 @@ async def test_fixed_session_requests_are_serialized(aiohttp_client) -> None: r1, r2 = await asyncio.gather(send("first"), send("second")) assert r1.status == 200 assert r2.status == 200 - assert order.index("end:first") < order.index("start:second") + # Verify serialization: one process must fully finish before the other starts + if order[0] == "start:first": + assert order.index("end:first") < order.index("start:second") + else: + assert order.index("end:second") < order.index("start:first") @pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") From fbedf7ad77a9999a2462ece74e97255e2e9ecb70 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Wed, 1 Apr 2026 19:12:49 +0000 Subject: [PATCH 44/91] feat: harden agent runtime for long-running tasks --- nanobot/agent/context.py | 25 +- nanobot/agent/loop.py | 154 ++++++++-- nanobot/agent/runner.py | 305 ++++++++++++++++++-- nanobot/agent/subagent.py | 3 + nanobot/agent/tools/base.py | 15 + nanobot/agent/tools/filesystem.py | 8 + nanobot/agent/tools/registry.py | 35 ++- nanobot/agent/tools/shell.py | 4 + nanobot/agent/tools/web.py | 8 + nanobot/cli/commands.py | 9 + nanobot/config/schema.py | 5 +- nanobot/nanobot.py | 3 + nanobot/providers/anthropic_provider.py | 26 +- nanobot/providers/base.py | 149 +++++++--- nanobot/providers/openai_compat_provider.py | 21 +- nanobot/session/manager.py | 44 +-- nanobot/utils/helpers.py | 160 +++++++++- tests/agent/test_context_prompt_cache.py | 16 + tests/agent/test_loop_save_turn.py | 130 ++++++++- tests/agent/test_runner.py | 255 +++++++++++++++- tests/agent/test_task_cancel.py | 60 +++- tests/channels/test_discord_channel.py | 6 +- tests/providers/test_litellm_kwargs.py | 61 ++++ tests/providers/test_provider_retry.py | 29 ++ tests/tools/test_mcp_tool.py | 2 +- 25 files changed, 1348 insertions(+), 185 deletions(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index ce69d247..8ce2873a 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -110,6 +110,20 @@ IMPORTANT: To send files (images, documents, audio, video) to the user, you MUST lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"] return ContextBuilder._RUNTIME_CONTEXT_TAG + "\n" + "\n".join(lines) + @staticmethod + def _merge_message_content(left: Any, right: Any) -> str | list[dict[str, Any]]: + if isinstance(left, str) and isinstance(right, str): + return f"{left}\n\n{right}" if left else right + + def _to_blocks(value: Any) -> list[dict[str, Any]]: + if isinstance(value, list): + return [item if isinstance(item, dict) else {"type": "text", "text": str(item)} for item in value] + if value is None: + return [] + return [{"type": "text", "text": str(value)}] + + return _to_blocks(left) + _to_blocks(right) + def _load_bootstrap_files(self) -> str: """Load all bootstrap files from workspace.""" parts = [] @@ -142,12 +156,17 @@ IMPORTANT: To send files (images, documents, audio, video) to the user, you MUST merged = f"{runtime_ctx}\n\n{user_content}" else: merged = [{"type": "text", "text": runtime_ctx}] + user_content - - return [ + messages = [ {"role": "system", "content": self.build_system_prompt(skill_names)}, *history, - {"role": current_role, "content": merged}, ] + if messages[-1].get("role") == current_role: + last = dict(messages[-1]) + last["content"] = self._merge_message_content(last.get("content"), merged) + messages[-1] = last + return messages + messages.append({"role": current_role, "content": merged}) + return messages def _build_user_content(self, text: str, media: list[str] | None) -> str | list[dict[str, Any]]: """Build user message content with optional base64-encoded images.""" diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index a9dc589e..d231ba9a 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -29,8 +29,10 @@ from nanobot.agent.tools.web import WebFetchTool, WebSearchTool from nanobot.bus.events import InboundMessage, OutboundMessage from nanobot.command import CommandContext, CommandRouter, register_builtin_commands from nanobot.bus.queue import MessageBus +from nanobot.config.schema import AgentDefaults from nanobot.providers.base import LLMProvider from nanobot.session.manager import Session, SessionManager +from nanobot.utils.helpers import image_placeholder_text, truncate_text if TYPE_CHECKING: from nanobot.config.schema import ChannelsConfig, ExecToolConfig, WebSearchConfig @@ -38,11 +40,7 @@ if TYPE_CHECKING: class _LoopHook(AgentHook): - """Core lifecycle hook for the main agent loop. - - Handles streaming delta relay, progress reporting, tool-call logging, - and think-tag stripping for the built-in agent path. - """ + """Core hook for the main loop.""" def __init__( self, @@ -102,11 +100,7 @@ class _LoopHook(AgentHook): class _LoopHookChain(AgentHook): - """Run the core loop hook first, then best-effort extra hooks. - - This preserves the historical failure behavior of ``_LoopHook`` while still - letting user-supplied hooks opt into ``CompositeHook`` isolation. - """ + """Run the core hook before extra hooks.""" __slots__ = ("_primary", "_extras") @@ -154,7 +148,7 @@ class AgentLoop: 5. Sends responses back """ - _TOOL_RESULT_MAX_CHARS = 16_000 + _RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint" def __init__( self, @@ -162,8 +156,11 @@ class AgentLoop: provider: LLMProvider, workspace: Path, model: str | None = None, - max_iterations: int = 40, - context_window_tokens: int = 65_536, + max_iterations: int | None = None, + context_window_tokens: int | None = None, + context_block_limit: int | None = None, + max_tool_result_chars: int | None = None, + provider_retry_mode: str = "standard", web_search_config: WebSearchConfig | None = None, web_proxy: str | None = None, exec_config: ExecToolConfig | None = None, @@ -177,13 +174,27 @@ class AgentLoop: ): from nanobot.config.schema import ExecToolConfig, WebSearchConfig + defaults = AgentDefaults() self.bus = bus self.channels_config = channels_config self.provider = provider self.workspace = workspace self.model = model or provider.get_default_model() - self.max_iterations = max_iterations - self.context_window_tokens = context_window_tokens + self.max_iterations = ( + max_iterations if max_iterations is not None else defaults.max_tool_iterations + ) + self.context_window_tokens = ( + context_window_tokens + if context_window_tokens is not None + else defaults.context_window_tokens + ) + self.context_block_limit = context_block_limit + self.max_tool_result_chars = ( + max_tool_result_chars + if max_tool_result_chars is not None + else defaults.max_tool_result_chars + ) + self.provider_retry_mode = provider_retry_mode self.web_search_config = web_search_config or WebSearchConfig() self.web_proxy = web_proxy self.exec_config = exec_config or ExecToolConfig() @@ -202,6 +213,7 @@ class AgentLoop: workspace=workspace, bus=bus, model=self.model, + max_tool_result_chars=self.max_tool_result_chars, web_search_config=self.web_search_config, web_proxy=web_proxy, exec_config=self.exec_config, @@ -313,6 +325,7 @@ class AgentLoop: on_stream: Callable[[str], Awaitable[None]] | None = None, on_stream_end: Callable[..., Awaitable[None]] | None = None, *, + session: Session | None = None, channel: str = "cli", chat_id: str = "direct", message_id: str | None = None, @@ -339,14 +352,27 @@ class AgentLoop: else loop_hook ) + async def _checkpoint(payload: dict[str, Any]) -> None: + if session is None: + return + self._set_runtime_checkpoint(session, payload) + result = await self.runner.run(AgentRunSpec( initial_messages=initial_messages, tools=self.tools, model=self.model, max_iterations=self.max_iterations, + max_tool_result_chars=self.max_tool_result_chars, hook=hook, error_message="Sorry, I encountered an error calling the AI model.", concurrent_tools=True, + workspace=self.workspace, + session_key=session.key if session else None, + context_window_tokens=self.context_window_tokens, + context_block_limit=self.context_block_limit, + provider_retry_mode=self.provider_retry_mode, + progress_callback=on_progress, + checkpoint_callback=_checkpoint, )) self._last_usage = result.usage if result.stop_reason == "max_iterations": @@ -484,6 +510,8 @@ class AgentLoop: logger.info("Processing system message from {}", msg.sender_id) key = f"{channel}:{chat_id}" session = self.sessions.get_or_create(key) + if self._restore_runtime_checkpoint(session): + self.sessions.save(session) await self.memory_consolidator.maybe_consolidate_by_tokens(session) self._set_tool_context(channel, chat_id, msg.metadata.get("message_id")) history = session.get_history(max_messages=0) @@ -494,10 +522,11 @@ class AgentLoop: current_role=current_role, ) final_content, _, all_msgs = await self._run_agent_loop( - messages, channel=channel, chat_id=chat_id, + messages, session=session, channel=channel, chat_id=chat_id, message_id=msg.metadata.get("message_id"), ) self._save_turn(session, all_msgs, 1 + len(history)) + self._clear_runtime_checkpoint(session) self.sessions.save(session) self._schedule_background(self.memory_consolidator.maybe_consolidate_by_tokens(session)) return OutboundMessage(channel=channel, chat_id=chat_id, @@ -508,6 +537,8 @@ class AgentLoop: key = session_key or msg.session_key session = self.sessions.get_or_create(key) + if self._restore_runtime_checkpoint(session): + self.sessions.save(session) # Slash commands raw = msg.content.strip() @@ -543,6 +574,7 @@ class AgentLoop: on_progress=on_progress or _bus_progress, on_stream=on_stream, on_stream_end=on_stream_end, + session=session, channel=msg.channel, chat_id=msg.chat_id, message_id=msg.metadata.get("message_id"), ) @@ -551,6 +583,7 @@ class AgentLoop: final_content = "I've completed processing but have no response to give." self._save_turn(session, all_msgs, 1 + len(history)) + self._clear_runtime_checkpoint(session) self.sessions.save(session) self._schedule_background(self.memory_consolidator.maybe_consolidate_by_tokens(session)) @@ -568,12 +601,6 @@ class AgentLoop: metadata=meta, ) - @staticmethod - def _image_placeholder(block: dict[str, Any]) -> dict[str, str]: - """Convert an inline image block into a compact text placeholder.""" - path = (block.get("_meta") or {}).get("path", "") - return {"type": "text", "text": f"[image: {path}]" if path else "[image]"} - def _sanitize_persisted_blocks( self, content: list[dict[str, Any]], @@ -600,13 +627,14 @@ class AgentLoop: block.get("type") == "image_url" and block.get("image_url", {}).get("url", "").startswith("data:image/") ): - filtered.append(self._image_placeholder(block)) + path = (block.get("_meta") or {}).get("path", "") + filtered.append({"type": "text", "text": image_placeholder_text(path)}) continue if block.get("type") == "text" and isinstance(block.get("text"), str): text = block["text"] - if truncate_text and len(text) > self._TOOL_RESULT_MAX_CHARS: - text = text[:self._TOOL_RESULT_MAX_CHARS] + "\n... (truncated)" + if truncate_text and len(text) > self.max_tool_result_chars: + text = truncate_text(text, self.max_tool_result_chars) filtered.append({**block, "text": text}) continue @@ -623,8 +651,8 @@ class AgentLoop: if role == "assistant" and not content and not entry.get("tool_calls"): continue # skip empty assistant messages — they poison session context if role == "tool": - if isinstance(content, str) and len(content) > self._TOOL_RESULT_MAX_CHARS: - entry["content"] = content[:self._TOOL_RESULT_MAX_CHARS] + "\n... (truncated)" + if isinstance(content, str) and len(content) > self.max_tool_result_chars: + entry["content"] = truncate_text(content, self.max_tool_result_chars) elif isinstance(content, list): filtered = self._sanitize_persisted_blocks(content, truncate_text=True) if not filtered: @@ -647,6 +675,78 @@ class AgentLoop: session.messages.append(entry) session.updated_at = datetime.now() + def _set_runtime_checkpoint(self, session: Session, payload: dict[str, Any]) -> None: + """Persist the latest in-flight turn state into session metadata.""" + session.metadata[self._RUNTIME_CHECKPOINT_KEY] = payload + self.sessions.save(session) + + def _clear_runtime_checkpoint(self, session: Session) -> None: + if self._RUNTIME_CHECKPOINT_KEY in session.metadata: + session.metadata.pop(self._RUNTIME_CHECKPOINT_KEY, None) + + @staticmethod + def _checkpoint_message_key(message: dict[str, Any]) -> tuple[Any, ...]: + return ( + message.get("role"), + message.get("content"), + message.get("tool_call_id"), + message.get("name"), + message.get("tool_calls"), + message.get("reasoning_content"), + message.get("thinking_blocks"), + ) + + def _restore_runtime_checkpoint(self, session: Session) -> bool: + """Materialize an unfinished turn into session history before a new request.""" + from datetime import datetime + + checkpoint = session.metadata.get(self._RUNTIME_CHECKPOINT_KEY) + if not isinstance(checkpoint, dict): + return False + + assistant_message = checkpoint.get("assistant_message") + completed_tool_results = checkpoint.get("completed_tool_results") or [] + pending_tool_calls = checkpoint.get("pending_tool_calls") or [] + + restored_messages: list[dict[str, Any]] = [] + if isinstance(assistant_message, dict): + restored = dict(assistant_message) + restored.setdefault("timestamp", datetime.now().isoformat()) + restored_messages.append(restored) + for message in completed_tool_results: + if isinstance(message, dict): + restored = dict(message) + restored.setdefault("timestamp", datetime.now().isoformat()) + restored_messages.append(restored) + for tool_call in pending_tool_calls: + if not isinstance(tool_call, dict): + continue + tool_id = tool_call.get("id") + name = ((tool_call.get("function") or {}).get("name")) or "tool" + restored_messages.append({ + "role": "tool", + "tool_call_id": tool_id, + "name": name, + "content": "Error: Task interrupted before this tool finished.", + "timestamp": datetime.now().isoformat(), + }) + + overlap = 0 + max_overlap = min(len(session.messages), len(restored_messages)) + for size in range(max_overlap, 0, -1): + existing = session.messages[-size:] + restored = restored_messages[:size] + if all( + self._checkpoint_message_key(left) == self._checkpoint_message_key(right) + for left, right in zip(existing, restored) + ): + overlap = size + break + session.messages.extend(restored_messages[overlap:]) + + self._clear_runtime_checkpoint(session) + return True + async def process_direct( self, content: str, diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index d6242a6b..64807368 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -4,20 +4,29 @@ from __future__ import annotations import asyncio from dataclasses import dataclass, field +from pathlib import Path from typing import Any +from loguru import logger + from nanobot.agent.hook import AgentHook, AgentHookContext from nanobot.agent.tools.registry import ToolRegistry from nanobot.providers.base import LLMProvider, ToolCallRequest -from nanobot.utils.helpers import build_assistant_message +from nanobot.utils.helpers import ( + build_assistant_message, + estimate_message_tokens, + estimate_prompt_tokens_chain, + find_legal_message_start, + maybe_persist_tool_result, + truncate_text, +) _DEFAULT_MAX_ITERATIONS_MESSAGE = ( "I reached the maximum number of tool call iterations ({max_iterations}) " "without completing the task. You can try breaking the task into smaller steps." ) _DEFAULT_ERROR_MESSAGE = "Sorry, I encountered an error calling the AI model." - - +_SNIP_SAFETY_BUFFER = 1024 @dataclass(slots=True) class AgentRunSpec: """Configuration for a single agent execution.""" @@ -26,6 +35,7 @@ class AgentRunSpec: tools: ToolRegistry model: str max_iterations: int + max_tool_result_chars: int temperature: float | None = None max_tokens: int | None = None reasoning_effort: str | None = None @@ -34,6 +44,13 @@ class AgentRunSpec: max_iterations_message: str | None = None concurrent_tools: bool = False fail_on_tool_error: bool = False + workspace: Path | None = None + session_key: str | None = None + context_window_tokens: int | None = None + context_block_limit: int | None = None + provider_retry_mode: str = "standard" + progress_callback: Any | None = None + checkpoint_callback: Any | None = None @dataclass(slots=True) @@ -66,12 +83,25 @@ class AgentRunner: tool_events: list[dict[str, str]] = [] for iteration in range(spec.max_iterations): + try: + messages = self._apply_tool_result_budget(spec, messages) + messages_for_model = self._snip_history(spec, messages) + except Exception as exc: + logger.warning( + "Context governance failed on turn {} for {}: {}; using raw messages", + iteration, + spec.session_key or "default", + exc, + ) + messages_for_model = messages context = AgentHookContext(iteration=iteration, messages=messages) await hook.before_iteration(context) kwargs: dict[str, Any] = { - "messages": messages, + "messages": messages_for_model, "tools": spec.tools.get_definitions(), "model": spec.model, + "retry_mode": spec.provider_retry_mode, + "on_retry_wait": spec.progress_callback, } if spec.temperature is not None: kwargs["temperature"] = spec.temperature @@ -104,13 +134,25 @@ class AgentRunner: if hook.wants_streaming(): await hook.on_stream_end(context, resuming=True) - messages.append(build_assistant_message( + assistant_message = build_assistant_message( response.content or "", tool_calls=[tc.to_openai_tool_call() for tc in response.tool_calls], reasoning_content=response.reasoning_content, thinking_blocks=response.thinking_blocks, - )) + ) + messages.append(assistant_message) tools_used.extend(tc.name for tc in response.tool_calls) + await self._emit_checkpoint( + spec, + { + "phase": "awaiting_tools", + "iteration": iteration, + "model": spec.model, + "assistant_message": assistant_message, + "completed_tool_results": [], + "pending_tool_calls": [tc.to_openai_tool_call() for tc in response.tool_calls], + }, + ) await hook.before_execute_tools(context) @@ -125,13 +167,31 @@ class AgentRunner: context.stop_reason = stop_reason await hook.after_iteration(context) break + completed_tool_results: list[dict[str, Any]] = [] for tool_call, result in zip(response.tool_calls, results): - messages.append({ + tool_message = { "role": "tool", "tool_call_id": tool_call.id, "name": tool_call.name, - "content": result, - }) + "content": self._normalize_tool_result( + spec, + tool_call.id, + result, + ), + } + messages.append(tool_message) + completed_tool_results.append(tool_message) + await self._emit_checkpoint( + spec, + { + "phase": "tools_completed", + "iteration": iteration, + "model": spec.model, + "assistant_message": assistant_message, + "completed_tool_results": completed_tool_results, + "pending_tool_calls": [], + }, + ) await hook.after_iteration(context) continue @@ -143,6 +203,7 @@ class AgentRunner: final_content = clean or spec.error_message or _DEFAULT_ERROR_MESSAGE stop_reason = "error" error = final_content + self._append_final_message(messages, final_content) context.final_content = final_content context.error = error context.stop_reason = stop_reason @@ -154,6 +215,17 @@ class AgentRunner: reasoning_content=response.reasoning_content, thinking_blocks=response.thinking_blocks, )) + await self._emit_checkpoint( + spec, + { + "phase": "final_response", + "iteration": iteration, + "model": spec.model, + "assistant_message": messages[-1], + "completed_tool_results": [], + "pending_tool_calls": [], + }, + ) final_content = clean context.final_content = final_content context.stop_reason = stop_reason @@ -163,6 +235,7 @@ class AgentRunner: stop_reason = "max_iterations" template = spec.max_iterations_message or _DEFAULT_MAX_ITERATIONS_MESSAGE final_content = template.format(max_iterations=spec.max_iterations) + self._append_final_message(messages, final_content) return AgentRunResult( final_content=final_content, @@ -179,16 +252,17 @@ class AgentRunner: spec: AgentRunSpec, tool_calls: list[ToolCallRequest], ) -> tuple[list[Any], list[dict[str, str]], BaseException | None]: - if spec.concurrent_tools: - tool_results = await asyncio.gather(*( - self._run_tool(spec, tool_call) - for tool_call in tool_calls - )) - else: - tool_results = [ - await self._run_tool(spec, tool_call) - for tool_call in tool_calls - ] + batches = self._partition_tool_batches(spec, tool_calls) + tool_results: list[tuple[Any, dict[str, str], BaseException | None]] = [] + for batch in batches: + if spec.concurrent_tools and len(batch) > 1: + tool_results.extend(await asyncio.gather(*( + self._run_tool(spec, tool_call) + for tool_call in batch + ))) + else: + for tool_call in batch: + tool_results.append(await self._run_tool(spec, tool_call)) results: list[Any] = [] events: list[dict[str, str]] = [] @@ -205,8 +279,28 @@ class AgentRunner: spec: AgentRunSpec, tool_call: ToolCallRequest, ) -> tuple[Any, dict[str, str], BaseException | None]: + _HINT = "\n\n[Analyze the error above and try a different approach.]" + prepare_call = getattr(spec.tools, "prepare_call", None) + tool, params, prep_error = None, tool_call.arguments, None + if callable(prepare_call): + try: + prepared = prepare_call(tool_call.name, tool_call.arguments) + if isinstance(prepared, tuple) and len(prepared) == 3: + tool, params, prep_error = prepared + except Exception: + pass + if prep_error: + event = { + "name": tool_call.name, + "status": "error", + "detail": prep_error.split(": ", 1)[-1][:120], + } + return prep_error + _HINT, event, RuntimeError(prep_error) if spec.fail_on_tool_error else None try: - result = await spec.tools.execute(tool_call.name, tool_call.arguments) + if tool is not None: + result = await tool.execute(**params) + else: + result = await spec.tools.execute(tool_call.name, params) except asyncio.CancelledError: raise except BaseException as exc: @@ -219,14 +313,175 @@ class AgentRunner: return f"Error: {type(exc).__name__}: {exc}", event, exc return f"Error: {type(exc).__name__}: {exc}", event, None + if isinstance(result, str) and result.startswith("Error"): + event = { + "name": tool_call.name, + "status": "error", + "detail": result.replace("\n", " ").strip()[:120], + } + if spec.fail_on_tool_error: + return result + _HINT, event, RuntimeError(result) + return result + _HINT, event, None + detail = "" if result is None else str(result) detail = detail.replace("\n", " ").strip() if not detail: detail = "(empty)" elif len(detail) > 120: detail = detail[:120] + "..." - return result, { - "name": tool_call.name, - "status": "error" if isinstance(result, str) and result.startswith("Error") else "ok", - "detail": detail, - }, None + return result, {"name": tool_call.name, "status": "ok", "detail": detail}, None + + async def _emit_checkpoint( + self, + spec: AgentRunSpec, + payload: dict[str, Any], + ) -> None: + callback = spec.checkpoint_callback + if callback is not None: + await callback(payload) + + @staticmethod + def _append_final_message(messages: list[dict[str, Any]], content: str | None) -> None: + if not content: + return + if ( + messages + and messages[-1].get("role") == "assistant" + and not messages[-1].get("tool_calls") + ): + if messages[-1].get("content") == content: + return + messages[-1] = build_assistant_message(content) + return + messages.append(build_assistant_message(content)) + + def _normalize_tool_result( + self, + spec: AgentRunSpec, + tool_call_id: str, + result: Any, + ) -> Any: + try: + content = maybe_persist_tool_result( + spec.workspace, + spec.session_key, + tool_call_id, + result, + max_chars=spec.max_tool_result_chars, + ) + except Exception as exc: + logger.warning( + "Tool result persist failed for {} in {}: {}; using raw result", + tool_call_id, + spec.session_key or "default", + exc, + ) + content = result + if isinstance(content, str) and len(content) > spec.max_tool_result_chars: + return truncate_text(content, spec.max_tool_result_chars) + return content + + def _apply_tool_result_budget( + self, + spec: AgentRunSpec, + messages: list[dict[str, Any]], + ) -> list[dict[str, Any]]: + updated = messages + for idx, message in enumerate(messages): + if message.get("role") != "tool": + continue + normalized = self._normalize_tool_result( + spec, + str(message.get("tool_call_id") or f"tool_{idx}"), + message.get("content"), + ) + if normalized != message.get("content"): + if updated is messages: + updated = [dict(m) for m in messages] + updated[idx]["content"] = normalized + return updated + + def _snip_history( + self, + spec: AgentRunSpec, + messages: list[dict[str, Any]], + ) -> list[dict[str, Any]]: + if not messages or not spec.context_window_tokens: + return messages + + provider_max_tokens = getattr(getattr(self.provider, "generation", None), "max_tokens", 4096) + max_output = spec.max_tokens if isinstance(spec.max_tokens, int) else ( + provider_max_tokens if isinstance(provider_max_tokens, int) else 4096 + ) + budget = spec.context_block_limit or ( + spec.context_window_tokens - max_output - _SNIP_SAFETY_BUFFER + ) + if budget <= 0: + return messages + + estimate, _ = estimate_prompt_tokens_chain( + self.provider, + spec.model, + messages, + spec.tools.get_definitions(), + ) + if estimate <= budget: + return messages + + system_messages = [dict(msg) for msg in messages if msg.get("role") == "system"] + non_system = [dict(msg) for msg in messages if msg.get("role") != "system"] + if not non_system: + return messages + + system_tokens = sum(estimate_message_tokens(msg) for msg in system_messages) + remaining_budget = max(128, budget - system_tokens) + kept: list[dict[str, Any]] = [] + kept_tokens = 0 + for message in reversed(non_system): + msg_tokens = estimate_message_tokens(message) + if kept and kept_tokens + msg_tokens > remaining_budget: + break + kept.append(message) + kept_tokens += msg_tokens + kept.reverse() + + if kept: + for i, message in enumerate(kept): + if message.get("role") == "user": + kept = kept[i:] + break + start = find_legal_message_start(kept) + if start: + kept = kept[start:] + if not kept: + kept = non_system[-min(len(non_system), 4) :] + start = find_legal_message_start(kept) + if start: + kept = kept[start:] + return system_messages + kept + + def _partition_tool_batches( + self, + spec: AgentRunSpec, + tool_calls: list[ToolCallRequest], + ) -> list[list[ToolCallRequest]]: + if not spec.concurrent_tools: + return [[tool_call] for tool_call in tool_calls] + + batches: list[list[ToolCallRequest]] = [] + current: list[ToolCallRequest] = [] + for tool_call in tool_calls: + get_tool = getattr(spec.tools, "get", None) + tool = get_tool(tool_call.name) if callable(get_tool) else None + can_batch = bool(tool and tool.concurrency_safe) + if can_batch: + current.append(tool_call) + continue + if current: + batches.append(current) + current = [] + batches.append([tool_call]) + if current: + batches.append(current) + return batches + diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 9d936f03..c7643a48 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -44,6 +44,7 @@ class SubagentManager: provider: LLMProvider, workspace: Path, bus: MessageBus, + max_tool_result_chars: int, model: str | None = None, web_search_config: "WebSearchConfig | None" = None, web_proxy: str | None = None, @@ -56,6 +57,7 @@ class SubagentManager: self.workspace = workspace self.bus = bus self.model = model or provider.get_default_model() + self.max_tool_result_chars = max_tool_result_chars self.web_search_config = web_search_config or WebSearchConfig() self.web_proxy = web_proxy self.exec_config = exec_config or ExecToolConfig() @@ -136,6 +138,7 @@ class SubagentManager: tools=tools, model=self.model, max_iterations=15, + max_tool_result_chars=self.max_tool_result_chars, hook=_SubagentHook(task_id), max_iterations_message="Task completed but no final response was generated.", error_message=None, diff --git a/nanobot/agent/tools/base.py b/nanobot/agent/tools/base.py index 4017f7cf..f119f690 100644 --- a/nanobot/agent/tools/base.py +++ b/nanobot/agent/tools/base.py @@ -53,6 +53,21 @@ class Tool(ABC): """JSON Schema for tool parameters.""" pass + @property + def read_only(self) -> bool: + """Whether this tool is side-effect free and safe to parallelize.""" + return False + + @property + def concurrency_safe(self) -> bool: + """Whether this tool can run alongside other concurrency-safe tools.""" + return self.read_only and not self.exclusive + + @property + def exclusive(self) -> bool: + """Whether this tool should run alone even if concurrency is enabled.""" + return False + @abstractmethod async def execute(self, **kwargs: Any) -> Any: """ diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index da7778da..d4094e7f 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -73,6 +73,10 @@ class ReadFileTool(_FsTool): "Use offset and limit to paginate through large files." ) + @property + def read_only(self) -> bool: + return True + @property def parameters(self) -> dict[str, Any]: return { @@ -344,6 +348,10 @@ class ListDirTool(_FsTool): "Common noise directories (.git, node_modules, __pycache__, etc.) are auto-ignored." ) + @property + def read_only(self) -> bool: + return True + @property def parameters(self) -> dict[str, Any]: return { diff --git a/nanobot/agent/tools/registry.py b/nanobot/agent/tools/registry.py index c24659a7..725706dc 100644 --- a/nanobot/agent/tools/registry.py +++ b/nanobot/agent/tools/registry.py @@ -35,22 +35,35 @@ class ToolRegistry: """Get all tool definitions in OpenAI format.""" return [tool.to_schema() for tool in self._tools.values()] + def prepare_call( + self, + name: str, + params: dict[str, Any], + ) -> tuple[Tool | None, dict[str, Any], str | None]: + """Resolve, cast, and validate one tool call.""" + tool = self._tools.get(name) + if not tool: + return None, params, ( + f"Error: Tool '{name}' not found. Available: {', '.join(self.tool_names)}" + ) + + cast_params = tool.cast_params(params) + errors = tool.validate_params(cast_params) + if errors: + return tool, cast_params, ( + f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors) + ) + return tool, cast_params, None + async def execute(self, name: str, params: dict[str, Any]) -> Any: """Execute a tool by name with given parameters.""" _HINT = "\n\n[Analyze the error above and try a different approach.]" - - tool = self._tools.get(name) - if not tool: - return f"Error: Tool '{name}' not found. Available: {', '.join(self.tool_names)}" + tool, params, error = self.prepare_call(name, params) + if error: + return error + _HINT try: - # Attempt to cast parameters to match schema types - params = tool.cast_params(params) - - # Validate parameters - errors = tool.validate_params(params) - if errors: - return f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors) + _HINT + assert tool is not None # guarded by prepare_call() result = await tool.execute(**params) if isinstance(result, str) and result.startswith("Error"): return result + _HINT diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index ed552b33..89e3d0e8 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -52,6 +52,10 @@ class ExecTool(Tool): def description(self) -> str: return "Execute a shell command and return its output. Use with caution." + @property + def exclusive(self) -> bool: + return True + @property def parameters(self) -> dict[str, Any]: return { diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index 9480e194..1c0fde82 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -92,6 +92,10 @@ class WebSearchTool(Tool): self.config = config if config is not None else WebSearchConfig() self.proxy = proxy + @property + def read_only(self) -> bool: + return True + async def execute(self, query: str, count: int | None = None, **kwargs: Any) -> str: provider = self.config.provider.strip().lower() or "brave" n = min(max(count or self.config.max_results, 1), 10) @@ -234,6 +238,10 @@ class WebFetchTool(Tool): self.max_chars = max_chars self.proxy = proxy + @property + def read_only(self) -> bool: + return True + async def execute(self, url: str, extractMode: str = "markdown", maxChars: int | None = None, **kwargs: Any) -> Any: max_chars = maxChars or self.max_chars is_valid, error_msg = _validate_url_safe(url) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 7f7d24f3..ad41355e 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -539,6 +539,9 @@ def serve( model=runtime_config.agents.defaults.model, max_iterations=runtime_config.agents.defaults.max_tool_iterations, context_window_tokens=runtime_config.agents.defaults.context_window_tokens, + context_block_limit=runtime_config.agents.defaults.context_block_limit, + max_tool_result_chars=runtime_config.agents.defaults.max_tool_result_chars, + provider_retry_mode=runtime_config.agents.defaults.provider_retry_mode, web_search_config=runtime_config.tools.web.search, web_proxy=runtime_config.tools.web.proxy or None, exec_config=runtime_config.tools.exec, @@ -626,6 +629,9 @@ def gateway( model=config.agents.defaults.model, max_iterations=config.agents.defaults.max_tool_iterations, context_window_tokens=config.agents.defaults.context_window_tokens, + context_block_limit=config.agents.defaults.context_block_limit, + max_tool_result_chars=config.agents.defaults.max_tool_result_chars, + provider_retry_mode=config.agents.defaults.provider_retry_mode, web_search_config=config.tools.web.search, web_proxy=config.tools.web.proxy or None, exec_config=config.tools.exec, @@ -832,6 +838,9 @@ def agent( model=config.agents.defaults.model, max_iterations=config.agents.defaults.max_tool_iterations, context_window_tokens=config.agents.defaults.context_window_tokens, + context_block_limit=config.agents.defaults.context_block_limit, + max_tool_result_chars=config.agents.defaults.max_tool_result_chars, + provider_retry_mode=config.agents.defaults.provider_retry_mode, web_search_config=config.tools.web.search, web_proxy=config.tools.web.proxy or None, exec_config=config.tools.exec, diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index c4c927af..602b8a91 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -38,8 +38,11 @@ class AgentDefaults(Base): ) max_tokens: int = 8192 context_window_tokens: int = 65_536 + context_block_limit: int | None = None temperature: float = 0.1 - max_tool_iterations: int = 40 + max_tool_iterations: int = 200 + max_tool_result_chars: int = 16_000 + provider_retry_mode: Literal["standard", "persistent"] = "standard" reasoning_effort: str | None = None # low / medium / high - enables LLM thinking mode timezone: str = "UTC" # IANA timezone, e.g. "Asia/Shanghai", "America/New_York" diff --git a/nanobot/nanobot.py b/nanobot/nanobot.py index 13768845..7e8dad0e 100644 --- a/nanobot/nanobot.py +++ b/nanobot/nanobot.py @@ -73,6 +73,9 @@ class Nanobot: model=defaults.model, max_iterations=defaults.max_tool_iterations, context_window_tokens=defaults.context_window_tokens, + context_block_limit=defaults.context_block_limit, + max_tool_result_chars=defaults.max_tool_result_chars, + provider_retry_mode=defaults.provider_retry_mode, web_search_config=config.tools.web.search, web_proxy=config.tools.web.proxy or None, exec_config=config.tools.exec, diff --git a/nanobot/providers/anthropic_provider.py b/nanobot/providers/anthropic_provider.py index 3c789e73..a6d2519d 100644 --- a/nanobot/providers/anthropic_provider.py +++ b/nanobot/providers/anthropic_provider.py @@ -2,6 +2,8 @@ from __future__ import annotations +import asyncio +import os import re import secrets import string @@ -427,13 +429,33 @@ class AnthropicProvider(LLMProvider): messages, tools, model, max_tokens, temperature, reasoning_effort, tool_choice, ) + idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90")) try: async with self._client.messages.stream(**kwargs) as stream: if on_content_delta: - async for text in stream.text_stream: + stream_iter = stream.text_stream.__aiter__() + while True: + try: + text = await asyncio.wait_for( + stream_iter.__anext__(), + timeout=idle_timeout_s, + ) + except StopAsyncIteration: + break await on_content_delta(text) - response = await stream.get_final_message() + response = await asyncio.wait_for( + stream.get_final_message(), + timeout=idle_timeout_s, + ) return self._parse_response(response) + except asyncio.TimeoutError: + return LLMResponse( + content=( + f"Error calling LLM: stream stalled for more than " + f"{idle_timeout_s} seconds" + ), + finish_reason="error", + ) except Exception as e: return LLMResponse(content=f"Error calling LLM: {e}", finish_reason="error") diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index 9ce2b0c6..c51f5dda 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -2,6 +2,7 @@ import asyncio import json +import re from abc import ABC, abstractmethod from collections.abc import Awaitable, Callable from dataclasses import dataclass, field @@ -9,6 +10,8 @@ from typing import Any from loguru import logger +from nanobot.utils.helpers import image_placeholder_text + @dataclass class ToolCallRequest: @@ -57,13 +60,7 @@ class LLMResponse: @dataclass(frozen=True) class GenerationSettings: - """Default generation parameters for LLM calls. - - Stored on the provider so every call site inherits the same defaults - without having to pass temperature / max_tokens / reasoning_effort - through every layer. Individual call sites can still override by - passing explicit keyword arguments to chat() / chat_with_retry(). - """ + """Default generation settings.""" temperature: float = 0.7 max_tokens: int = 4096 @@ -71,14 +68,11 @@ class GenerationSettings: class LLMProvider(ABC): - """ - Abstract base class for LLM providers. - - Implementations should handle the specifics of each provider's API - while maintaining a consistent interface. - """ + """Base class for LLM providers.""" _CHAT_RETRY_DELAYS = (1, 2, 4) + _PERSISTENT_MAX_DELAY = 60 + _RETRY_HEARTBEAT_CHUNK = 30 _TRANSIENT_ERROR_MARKERS = ( "429", "rate limit", @@ -208,7 +202,7 @@ class LLMProvider(ABC): for b in content: if isinstance(b, dict) and b.get("type") == "image_url": path = (b.get("_meta") or {}).get("path", "") - placeholder = f"[image: {path}]" if path else "[image omitted]" + placeholder = image_placeholder_text(path, empty="[image omitted]") new_content.append({"type": "text", "text": placeholder}) found = True else: @@ -273,6 +267,8 @@ class LLMProvider(ABC): reasoning_effort: object = _SENTINEL, tool_choice: str | dict[str, Any] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None, + retry_mode: str = "standard", + on_retry_wait: Callable[[str], Awaitable[None]] | None = None, ) -> LLMResponse: """Call chat_stream() with retry on transient provider failures.""" if max_tokens is self._SENTINEL: @@ -288,28 +284,13 @@ class LLMProvider(ABC): reasoning_effort=reasoning_effort, tool_choice=tool_choice, on_content_delta=on_content_delta, ) - - for attempt, delay in enumerate(self._CHAT_RETRY_DELAYS, start=1): - response = await self._safe_chat_stream(**kw) - - if response.finish_reason != "error": - return response - - if not self._is_transient_error(response.content): - stripped = self._strip_image_content(messages) - if stripped is not None: - logger.warning("Non-transient LLM error with image content, retrying without images") - return await self._safe_chat_stream(**{**kw, "messages": stripped}) - return response - - logger.warning( - "LLM transient error (attempt {}/{}), retrying in {}s: {}", - attempt, len(self._CHAT_RETRY_DELAYS), delay, - (response.content or "")[:120].lower(), - ) - await asyncio.sleep(delay) - - return await self._safe_chat_stream(**kw) + return await self._run_with_retry( + self._safe_chat_stream, + kw, + messages, + retry_mode=retry_mode, + on_retry_wait=on_retry_wait, + ) async def chat_with_retry( self, @@ -320,6 +301,8 @@ class LLMProvider(ABC): temperature: object = _SENTINEL, reasoning_effort: object = _SENTINEL, tool_choice: str | dict[str, Any] | None = None, + retry_mode: str = "standard", + on_retry_wait: Callable[[str], Awaitable[None]] | None = None, ) -> LLMResponse: """Call chat() with retry on transient provider failures. @@ -339,28 +322,102 @@ class LLMProvider(ABC): max_tokens=max_tokens, temperature=temperature, reasoning_effort=reasoning_effort, tool_choice=tool_choice, ) + return await self._run_with_retry( + self._safe_chat, + kw, + messages, + retry_mode=retry_mode, + on_retry_wait=on_retry_wait, + ) - for attempt, delay in enumerate(self._CHAT_RETRY_DELAYS, start=1): - response = await self._safe_chat(**kw) + @classmethod + def _extract_retry_after(cls, content: str | None) -> float | None: + text = (content or "").lower() + match = re.search(r"retry after\s+(\d+(?:\.\d+)?)\s*(ms|milliseconds|s|sec|secs|seconds|m|min|minutes)?", text) + if not match: + return None + value = float(match.group(1)) + unit = (match.group(2) or "s").lower() + if unit in {"ms", "milliseconds"}: + return max(0.1, value / 1000.0) + if unit in {"m", "min", "minutes"}: + return value * 60.0 + return value + async def _sleep_with_heartbeat( + self, + delay: float, + *, + attempt: int, + persistent: bool, + on_retry_wait: Callable[[str], Awaitable[None]] | None = None, + ) -> None: + remaining = max(0.0, delay) + while remaining > 0: + if on_retry_wait: + kind = "persistent retry" if persistent else "retry" + await on_retry_wait( + f"Model request failed, {kind} in {max(1, int(round(remaining)))}s " + f"(attempt {attempt})." + ) + chunk = min(remaining, self._RETRY_HEARTBEAT_CHUNK) + await asyncio.sleep(chunk) + remaining -= chunk + + async def _run_with_retry( + self, + call: Callable[..., Awaitable[LLMResponse]], + kw: dict[str, Any], + original_messages: list[dict[str, Any]], + *, + retry_mode: str, + on_retry_wait: Callable[[str], Awaitable[None]] | None, + ) -> LLMResponse: + attempt = 0 + delays = list(self._CHAT_RETRY_DELAYS) + persistent = retry_mode == "persistent" + last_response: LLMResponse | None = None + while True: + attempt += 1 + response = await call(**kw) if response.finish_reason != "error": return response + last_response = response if not self._is_transient_error(response.content): - stripped = self._strip_image_content(messages) - if stripped is not None: - logger.warning("Non-transient LLM error with image content, retrying without images") - return await self._safe_chat(**{**kw, "messages": stripped}) + stripped = self._strip_image_content(original_messages) + if stripped is not None and stripped != kw["messages"]: + logger.warning( + "Non-transient LLM error with image content, retrying without images" + ) + retry_kw = dict(kw) + retry_kw["messages"] = stripped + return await call(**retry_kw) return response + if not persistent and attempt > len(delays): + break + + base_delay = delays[min(attempt - 1, len(delays) - 1)] + delay = self._extract_retry_after(response.content) or base_delay + if persistent: + delay = min(delay, self._PERSISTENT_MAX_DELAY) + logger.warning( - "LLM transient error (attempt {}/{}), retrying in {}s: {}", - attempt, len(self._CHAT_RETRY_DELAYS), delay, + "LLM transient error (attempt {}{}), retrying in {}s: {}", + attempt, + "+" if persistent and attempt > len(delays) else f"/{len(delays)}", + int(round(delay)), (response.content or "")[:120].lower(), ) - await asyncio.sleep(delay) + await self._sleep_with_heartbeat( + delay, + attempt=attempt, + persistent=persistent, + on_retry_wait=on_retry_wait, + ) - return await self._safe_chat(**kw) + return last_response if last_response is not None else await call(**kw) @abstractmethod def get_default_model(self) -> str: diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index 397b8e79..2b7728c2 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import hashlib import os import secrets @@ -20,7 +21,6 @@ if TYPE_CHECKING: _ALLOWED_MSG_KEYS = frozenset({ "role", "content", "tool_calls", "tool_call_id", "name", - "reasoning_content", "extra_content", }) _ALNUM = string.ascii_letters + string.digits @@ -572,16 +572,33 @@ class OpenAICompatProvider(LLMProvider): ) kwargs["stream"] = True kwargs["stream_options"] = {"include_usage": True} + idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90")) try: stream = await self._client.chat.completions.create(**kwargs) chunks: list[Any] = [] - async for chunk in stream: + stream_iter = stream.__aiter__() + while True: + try: + chunk = await asyncio.wait_for( + stream_iter.__anext__(), + timeout=idle_timeout_s, + ) + except StopAsyncIteration: + break chunks.append(chunk) if on_content_delta and chunk.choices: text = getattr(chunk.choices[0].delta, "content", None) if text: await on_content_delta(text) return self._parse_chunks(chunks) + except asyncio.TimeoutError: + return LLMResponse( + content=( + f"Error calling LLM: stream stalled for more than " + f"{idle_timeout_s} seconds" + ), + finish_reason="error", + ) except Exception as e: return self._handle_error(e) diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 537ba42d..95e3916b 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -10,20 +10,12 @@ from typing import Any from loguru import logger from nanobot.config.paths import get_legacy_sessions_dir -from nanobot.utils.helpers import ensure_dir, safe_filename +from nanobot.utils.helpers import ensure_dir, find_legal_message_start, safe_filename @dataclass class Session: - """ - A conversation session. - - Stores messages in JSONL format for easy reading and persistence. - - Important: Messages are append-only for LLM cache efficiency. - The consolidation process writes summaries to MEMORY.md/HISTORY.md - but does NOT modify the messages list or get_history() output. - """ + """A conversation session.""" key: str # channel:chat_id messages: list[dict[str, Any]] = field(default_factory=list) @@ -43,43 +35,19 @@ class Session: self.messages.append(msg) self.updated_at = datetime.now() - @staticmethod - def _find_legal_start(messages: list[dict[str, Any]]) -> int: - """Find first index where every tool result has a matching assistant tool_call.""" - declared: set[str] = set() - start = 0 - for i, msg in enumerate(messages): - role = msg.get("role") - if role == "assistant": - for tc in msg.get("tool_calls") or []: - if isinstance(tc, dict) and tc.get("id"): - declared.add(str(tc["id"])) - elif role == "tool": - tid = msg.get("tool_call_id") - if tid and str(tid) not in declared: - start = i + 1 - declared.clear() - for prev in messages[start:i + 1]: - if prev.get("role") == "assistant": - for tc in prev.get("tool_calls") or []: - if isinstance(tc, dict) and tc.get("id"): - declared.add(str(tc["id"])) - return start - def get_history(self, max_messages: int = 500) -> list[dict[str, Any]]: """Return unconsolidated messages for LLM input, aligned to a legal tool-call boundary.""" unconsolidated = self.messages[self.last_consolidated:] sliced = unconsolidated[-max_messages:] - # Drop leading non-user messages to avoid starting mid-turn when possible. + # Avoid starting mid-turn when possible. for i, message in enumerate(sliced): if message.get("role") == "user": sliced = sliced[i:] break - # Some providers reject orphan tool results if the matching assistant - # tool_calls message fell outside the fixed-size history window. - start = self._find_legal_start(sliced) + # Drop orphan tool results at the front. + start = find_legal_message_start(sliced) if start: sliced = sliced[start:] @@ -115,7 +83,7 @@ class Session: retained = self.messages[start_idx:] # Mirror get_history(): avoid persisting orphan tool results at the front. - start = self._find_legal_start(retained) + start = find_legal_message_start(retained) if start: retained = retained[start:] diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index a7c2c257..6813c659 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -3,7 +3,9 @@ import base64 import json import re +import shutil import time +import uuid from datetime import datetime from pathlib import Path from typing import Any @@ -56,11 +58,7 @@ def timestamp() -> str: def current_time_str(timezone: str | None = None) -> str: - """Human-readable current time with weekday and UTC offset. - - When *timezone* is a valid IANA name (e.g. ``"Asia/Shanghai"``), the time - is converted to that zone. Otherwise falls back to the host local time. - """ + """Return the current time string.""" from zoneinfo import ZoneInfo try: @@ -76,12 +74,164 @@ def current_time_str(timezone: str | None = None) -> str: _UNSAFE_CHARS = re.compile(r'[<>:"/\\|?*]') +_TOOL_RESULT_PREVIEW_CHARS = 1200 +_TOOL_RESULTS_DIR = ".nanobot/tool-results" +_TOOL_RESULT_RETENTION_SECS = 7 * 24 * 60 * 60 +_TOOL_RESULT_MAX_BUCKETS = 32 def safe_filename(name: str) -> str: """Replace unsafe path characters with underscores.""" return _UNSAFE_CHARS.sub("_", name).strip() +def image_placeholder_text(path: str | None, *, empty: str = "[image]") -> str: + """Build an image placeholder string.""" + return f"[image: {path}]" if path else empty + + +def truncate_text(text: str, max_chars: int) -> str: + """Truncate text with a stable suffix.""" + if max_chars <= 0 or len(text) <= max_chars: + return text + return text[:max_chars] + "\n... (truncated)" + + +def find_legal_message_start(messages: list[dict[str, Any]]) -> int: + """Find the first index whose tool results have matching assistant calls.""" + declared: set[str] = set() + start = 0 + for i, msg in enumerate(messages): + role = msg.get("role") + if role == "assistant": + for tc in msg.get("tool_calls") or []: + if isinstance(tc, dict) and tc.get("id"): + declared.add(str(tc["id"])) + elif role == "tool": + tid = msg.get("tool_call_id") + if tid and str(tid) not in declared: + start = i + 1 + declared.clear() + for prev in messages[start : i + 1]: + if prev.get("role") == "assistant": + for tc in prev.get("tool_calls") or []: + if isinstance(tc, dict) and tc.get("id"): + declared.add(str(tc["id"])) + return start + + +def _stringify_text_blocks(content: list[dict[str, Any]]) -> str | None: + parts: list[str] = [] + for block in content: + if not isinstance(block, dict): + return None + if block.get("type") != "text": + return None + text = block.get("text") + if not isinstance(text, str): + return None + parts.append(text) + return "\n".join(parts) + + +def _render_tool_result_reference( + filepath: Path, + *, + original_size: int, + preview: str, + truncated_preview: bool, +) -> str: + result = ( + f"[tool output persisted]\n" + f"Full output saved to: {filepath}\n" + f"Original size: {original_size} chars\n" + f"Preview:\n{preview}" + ) + if truncated_preview: + result += "\n...\n(Read the saved file if you need the full output.)" + return result + + +def _bucket_mtime(path: Path) -> float: + try: + return path.stat().st_mtime + except OSError: + return 0.0 + + +def _cleanup_tool_result_buckets(root: Path, current_bucket: Path) -> None: + siblings = [path for path in root.iterdir() if path.is_dir() and path != current_bucket] + cutoff = time.time() - _TOOL_RESULT_RETENTION_SECS + for path in siblings: + if _bucket_mtime(path) < cutoff: + shutil.rmtree(path, ignore_errors=True) + keep = max(_TOOL_RESULT_MAX_BUCKETS - 1, 0) + siblings = [path for path in siblings if path.exists()] + if len(siblings) <= keep: + return + siblings.sort(key=_bucket_mtime, reverse=True) + for path in siblings[keep:]: + shutil.rmtree(path, ignore_errors=True) + + +def _write_text_atomic(path: Path, content: str) -> None: + tmp = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + try: + tmp.write_text(content, encoding="utf-8") + tmp.replace(path) + finally: + if tmp.exists(): + tmp.unlink(missing_ok=True) + + +def maybe_persist_tool_result( + workspace: Path | None, + session_key: str | None, + tool_call_id: str, + content: Any, + *, + max_chars: int, +) -> Any: + """Persist oversized tool output and replace it with a stable reference string.""" + if workspace is None or max_chars <= 0: + return content + + text_payload: str | None = None + suffix = "txt" + if isinstance(content, str): + text_payload = content + elif isinstance(content, list): + text_payload = _stringify_text_blocks(content) + if text_payload is None: + return content + suffix = "json" + else: + return content + + if len(text_payload) <= max_chars: + return content + + root = ensure_dir(workspace / _TOOL_RESULTS_DIR) + bucket = ensure_dir(root / safe_filename(session_key or "default")) + try: + _cleanup_tool_result_buckets(root, bucket) + except Exception: + pass + path = bucket / f"{safe_filename(tool_call_id)}.{suffix}" + if not path.exists(): + if suffix == "json" and isinstance(content, list): + _write_text_atomic(path, json.dumps(content, ensure_ascii=False, indent=2)) + else: + _write_text_atomic(path, text_payload) + + preview = text_payload[:_TOOL_RESULT_PREVIEW_CHARS] + return _render_tool_result_reference( + path, + original_size=len(text_payload), + preview=preview, + truncated_preview=len(text_payload) > _TOOL_RESULT_PREVIEW_CHARS, + ) + + def split_message(content: str, max_len: int = 2000) -> list[str]: """ Split content into chunks within max_len, preferring line breaks. diff --git a/tests/agent/test_context_prompt_cache.py b/tests/agent/test_context_prompt_cache.py index 6eb4b4f1..4484e5ed 100644 --- a/tests/agent/test_context_prompt_cache.py +++ b/tests/agent/test_context_prompt_cache.py @@ -71,3 +71,19 @@ def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None: assert "Channel: cli" in user_content assert "Chat ID: direct" in user_content assert "Return exactly: OK" in user_content + + +def test_subagent_result_does_not_create_consecutive_assistant_messages(tmp_path) -> None: + workspace = _make_workspace(tmp_path) + builder = ContextBuilder(workspace) + + messages = builder.build_messages( + history=[{"role": "assistant", "content": "previous result"}], + current_message="subagent result", + channel="cli", + chat_id="direct", + current_role="assistant", + ) + + for left, right in zip(messages, messages[1:]): + assert not (left.get("role") == right.get("role") == "assistant") diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py index aed7653c..8a0b54b8 100644 --- a/tests/agent/test_loop_save_turn.py +++ b/tests/agent/test_loop_save_turn.py @@ -5,7 +5,9 @@ from nanobot.session.manager import Session def _mk_loop() -> AgentLoop: loop = AgentLoop.__new__(AgentLoop) - loop._TOOL_RESULT_MAX_CHARS = AgentLoop._TOOL_RESULT_MAX_CHARS + from nanobot.config.schema import AgentDefaults + + loop.max_tool_result_chars = AgentDefaults().max_tool_result_chars return loop @@ -72,3 +74,129 @@ def test_save_turn_keeps_tool_results_under_16k() -> None: ) assert session.messages[0]["content"] == content + + +def test_restore_runtime_checkpoint_rehydrates_completed_and_pending_tools() -> None: + loop = _mk_loop() + session = Session( + key="test:checkpoint", + metadata={ + AgentLoop._RUNTIME_CHECKPOINT_KEY: { + "assistant_message": { + "role": "assistant", + "content": "working", + "tool_calls": [ + { + "id": "call_done", + "type": "function", + "function": {"name": "read_file", "arguments": "{}"}, + }, + { + "id": "call_pending", + "type": "function", + "function": {"name": "exec", "arguments": "{}"}, + }, + ], + }, + "completed_tool_results": [ + { + "role": "tool", + "tool_call_id": "call_done", + "name": "read_file", + "content": "ok", + } + ], + "pending_tool_calls": [ + { + "id": "call_pending", + "type": "function", + "function": {"name": "exec", "arguments": "{}"}, + } + ], + } + }, + ) + + restored = loop._restore_runtime_checkpoint(session) + + assert restored is True + assert session.metadata.get(AgentLoop._RUNTIME_CHECKPOINT_KEY) is None + assert session.messages[0]["role"] == "assistant" + assert session.messages[1]["tool_call_id"] == "call_done" + assert session.messages[2]["tool_call_id"] == "call_pending" + assert "interrupted before this tool finished" in session.messages[2]["content"].lower() + + +def test_restore_runtime_checkpoint_dedupes_overlapping_tail() -> None: + loop = _mk_loop() + session = Session( + key="test:checkpoint-overlap", + messages=[ + { + "role": "assistant", + "content": "working", + "tool_calls": [ + { + "id": "call_done", + "type": "function", + "function": {"name": "read_file", "arguments": "{}"}, + }, + { + "id": "call_pending", + "type": "function", + "function": {"name": "exec", "arguments": "{}"}, + }, + ], + }, + { + "role": "tool", + "tool_call_id": "call_done", + "name": "read_file", + "content": "ok", + }, + ], + metadata={ + AgentLoop._RUNTIME_CHECKPOINT_KEY: { + "assistant_message": { + "role": "assistant", + "content": "working", + "tool_calls": [ + { + "id": "call_done", + "type": "function", + "function": {"name": "read_file", "arguments": "{}"}, + }, + { + "id": "call_pending", + "type": "function", + "function": {"name": "exec", "arguments": "{}"}, + }, + ], + }, + "completed_tool_results": [ + { + "role": "tool", + "tool_call_id": "call_done", + "name": "read_file", + "content": "ok", + } + ], + "pending_tool_calls": [ + { + "id": "call_pending", + "type": "function", + "function": {"name": "exec", "arguments": "{}"}, + } + ], + } + }, + ) + + restored = loop._restore_runtime_checkpoint(session) + + assert restored is True + assert session.metadata.get(AgentLoop._RUNTIME_CHECKPOINT_KEY) is None + assert len(session.messages) == 3 + assert session.messages[0]["role"] == "assistant" + assert session.messages[1]["tool_call_id"] == "call_done" + assert session.messages[2]["tool_call_id"] == "call_pending" diff --git a/tests/agent/test_runner.py b/tests/agent/test_runner.py index 86b0ba71..f2a26820 100644 --- a/tests/agent/test_runner.py +++ b/tests/agent/test_runner.py @@ -2,12 +2,20 @@ from __future__ import annotations +import asyncio +import os +import time from unittest.mock import AsyncMock, MagicMock, patch import pytest +from nanobot.config.schema import AgentDefaults +from nanobot.agent.tools.base import Tool +from nanobot.agent.tools.registry import ToolRegistry from nanobot.providers.base import LLMResponse, ToolCallRequest +_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars + def _make_loop(tmp_path): from nanobot.agent.loop import AgentLoop @@ -60,6 +68,7 @@ async def test_runner_preserves_reasoning_fields_and_tool_results(): tools=tools, model="test-model", max_iterations=3, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, )) assert result.final_content == "done" @@ -135,6 +144,7 @@ async def test_runner_calls_hooks_in_order(): tools=tools, model="test-model", max_iterations=3, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, hook=RecordingHook(), )) @@ -191,6 +201,7 @@ async def test_runner_streaming_hook_receives_deltas_and_end_signal(): tools=tools, model="test-model", max_iterations=1, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, hook=StreamingHook(), )) @@ -219,6 +230,7 @@ async def test_runner_returns_max_iterations_fallback(): tools=tools, model="test-model", max_iterations=2, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, )) assert result.stop_reason == "max_iterations" @@ -226,7 +238,8 @@ async def test_runner_returns_max_iterations_fallback(): "I reached the maximum number of tool call iterations (2) " "without completing the task. You can try breaking the task into smaller steps." ) - + assert result.messages[-1]["role"] == "assistant" + assert result.messages[-1]["content"] == result.final_content @pytest.mark.asyncio async def test_runner_returns_structured_tool_error(): @@ -248,6 +261,7 @@ async def test_runner_returns_structured_tool_error(): tools=tools, model="test-model", max_iterations=2, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, fail_on_tool_error=True, )) @@ -258,6 +272,232 @@ async def test_runner_returns_structured_tool_error(): ] +@pytest.mark.asyncio +async def test_runner_persists_large_tool_results_for_follow_up_calls(tmp_path): + from nanobot.agent.runner import AgentRunSpec, AgentRunner + + provider = MagicMock() + captured_second_call: list[dict] = [] + call_count = {"n": 0} + + async def chat_with_retry(*, messages, **kwargs): + call_count["n"] += 1 + if call_count["n"] == 1: + return LLMResponse( + content="working", + tool_calls=[ToolCallRequest(id="call_big", name="list_dir", arguments={"path": "."})], + usage={"prompt_tokens": 5, "completion_tokens": 3}, + ) + captured_second_call[:] = messages + return LLMResponse(content="done", tool_calls=[], usage={}) + + provider.chat_with_retry = chat_with_retry + tools = MagicMock() + tools.get_definitions.return_value = [] + tools.execute = AsyncMock(return_value="x" * 20_000) + + runner = AgentRunner(provider) + result = await runner.run(AgentRunSpec( + initial_messages=[{"role": "user", "content": "do task"}], + tools=tools, + model="test-model", + max_iterations=2, + workspace=tmp_path, + session_key="test:runner", + max_tool_result_chars=2048, + )) + + assert result.final_content == "done" + tool_message = next(msg for msg in captured_second_call if msg.get("role") == "tool") + assert "[tool output persisted]" in tool_message["content"] + assert "tool-results" in tool_message["content"] + assert (tmp_path / ".nanobot" / "tool-results" / "test_runner" / "call_big.txt").exists() + + +def test_persist_tool_result_prunes_old_session_buckets(tmp_path): + from nanobot.utils.helpers import maybe_persist_tool_result + + root = tmp_path / ".nanobot" / "tool-results" + old_bucket = root / "old_session" + recent_bucket = root / "recent_session" + old_bucket.mkdir(parents=True) + recent_bucket.mkdir(parents=True) + (old_bucket / "old.txt").write_text("old", encoding="utf-8") + (recent_bucket / "recent.txt").write_text("recent", encoding="utf-8") + + stale = time.time() - (8 * 24 * 60 * 60) + os.utime(old_bucket, (stale, stale)) + os.utime(old_bucket / "old.txt", (stale, stale)) + + persisted = maybe_persist_tool_result( + tmp_path, + "current:session", + "call_big", + "x" * 5000, + max_chars=64, + ) + + assert "[tool output persisted]" in persisted + assert not old_bucket.exists() + assert recent_bucket.exists() + assert (root / "current_session" / "call_big.txt").exists() + + +def test_persist_tool_result_leaves_no_temp_files(tmp_path): + from nanobot.utils.helpers import maybe_persist_tool_result + + root = tmp_path / ".nanobot" / "tool-results" + maybe_persist_tool_result( + tmp_path, + "current:session", + "call_big", + "x" * 5000, + max_chars=64, + ) + + assert (root / "current_session" / "call_big.txt").exists() + assert list((root / "current_session").glob("*.tmp")) == [] + + +@pytest.mark.asyncio +async def test_runner_uses_raw_messages_when_context_governance_fails(): + from nanobot.agent.runner import AgentRunSpec, AgentRunner + + provider = MagicMock() + captured_messages: list[dict] = [] + + async def chat_with_retry(*, messages, **kwargs): + captured_messages[:] = messages + return LLMResponse(content="done", tool_calls=[], usage={}) + + provider.chat_with_retry = chat_with_retry + tools = MagicMock() + tools.get_definitions.return_value = [] + initial_messages = [ + {"role": "system", "content": "system"}, + {"role": "user", "content": "hello"}, + ] + + runner = AgentRunner(provider) + runner._snip_history = MagicMock(side_effect=RuntimeError("boom")) # type: ignore[method-assign] + result = await runner.run(AgentRunSpec( + initial_messages=initial_messages, + tools=tools, + model="test-model", + max_iterations=1, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + )) + + assert result.final_content == "done" + assert captured_messages == initial_messages + + +@pytest.mark.asyncio +async def test_runner_keeps_going_when_tool_result_persistence_fails(): + from nanobot.agent.runner import AgentRunSpec, AgentRunner + + provider = MagicMock() + captured_second_call: list[dict] = [] + call_count = {"n": 0} + + async def chat_with_retry(*, messages, **kwargs): + call_count["n"] += 1 + if call_count["n"] == 1: + return LLMResponse( + content="working", + tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={"path": "."})], + usage={"prompt_tokens": 5, "completion_tokens": 3}, + ) + captured_second_call[:] = messages + return LLMResponse(content="done", tool_calls=[], usage={}) + + provider.chat_with_retry = chat_with_retry + tools = MagicMock() + tools.get_definitions.return_value = [] + tools.execute = AsyncMock(return_value="tool result") + + runner = AgentRunner(provider) + with patch("nanobot.agent.runner.maybe_persist_tool_result", side_effect=RuntimeError("disk full")): + result = await runner.run(AgentRunSpec( + initial_messages=[{"role": "user", "content": "do task"}], + tools=tools, + model="test-model", + max_iterations=2, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + )) + + assert result.final_content == "done" + tool_message = next(msg for msg in captured_second_call if msg.get("role") == "tool") + assert tool_message["content"] == "tool result" + + +class _DelayTool(Tool): + def __init__(self, name: str, *, delay: float, read_only: bool, shared_events: list[str]): + self._name = name + self._delay = delay + self._read_only = read_only + self._shared_events = shared_events + + @property + def name(self) -> str: + return self._name + + @property + def description(self) -> str: + return self._name + + @property + def parameters(self) -> dict: + return {"type": "object", "properties": {}, "required": []} + + @property + def read_only(self) -> bool: + return self._read_only + + async def execute(self, **kwargs): + self._shared_events.append(f"start:{self._name}") + await asyncio.sleep(self._delay) + self._shared_events.append(f"end:{self._name}") + return self._name + + +@pytest.mark.asyncio +async def test_runner_batches_read_only_tools_before_exclusive_work(): + from nanobot.agent.runner import AgentRunSpec, AgentRunner + + tools = ToolRegistry() + shared_events: list[str] = [] + read_a = _DelayTool("read_a", delay=0.05, read_only=True, shared_events=shared_events) + read_b = _DelayTool("read_b", delay=0.05, read_only=True, shared_events=shared_events) + write_a = _DelayTool("write_a", delay=0.01, read_only=False, shared_events=shared_events) + tools.register(read_a) + tools.register(read_b) + tools.register(write_a) + + runner = AgentRunner(MagicMock()) + await runner._execute_tools( + AgentRunSpec( + initial_messages=[], + tools=tools, + model="test-model", + max_iterations=1, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + concurrent_tools=True, + ), + [ + ToolCallRequest(id="ro1", name="read_a", arguments={}), + ToolCallRequest(id="ro2", name="read_b", arguments={}), + ToolCallRequest(id="rw1", name="write_a", arguments={}), + ], + ) + + assert shared_events[0:2] == ["start:read_a", "start:read_b"] + assert "end:read_a" in shared_events and "end:read_b" in shared_events + assert shared_events.index("end:read_a") < shared_events.index("start:write_a") + assert shared_events.index("end:read_b") < shared_events.index("start:write_a") + assert shared_events[-2:] == ["start:write_a", "end:write_a"] + + @pytest.mark.asyncio async def test_loop_max_iterations_message_stays_stable(tmp_path): loop = _make_loop(tmp_path) @@ -317,15 +557,20 @@ async def test_subagent_max_iterations_announces_existing_fallback(tmp_path, mon provider.get_default_model.return_value = "test-model" provider.chat_with_retry = AsyncMock(return_value=LLMResponse( content="working", - tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={})], + tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={"path": "."})], )) - mgr = SubagentManager(provider=provider, workspace=tmp_path, bus=bus) + mgr = SubagentManager( + provider=provider, + workspace=tmp_path, + bus=bus, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + ) mgr._announce_result = AsyncMock() - async def fake_execute(self, name, arguments): + async def fake_execute(self, **kwargs): return "tool result" - monkeypatch.setattr("nanobot.agent.tools.registry.ToolRegistry.execute", fake_execute) + monkeypatch.setattr("nanobot.agent.tools.filesystem.ListDirTool.execute", fake_execute) await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"}) diff --git a/tests/agent/test_task_cancel.py b/tests/agent/test_task_cancel.py index 70f7621d..7e84e57d 100644 --- a/tests/agent/test_task_cancel.py +++ b/tests/agent/test_task_cancel.py @@ -8,6 +8,10 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from nanobot.config.schema import AgentDefaults + +_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars + def _make_loop(*, exec_config=None): """Create a minimal AgentLoop with mocked dependencies.""" @@ -186,7 +190,12 @@ class TestSubagentCancellation: bus = MessageBus() provider = MagicMock() provider.get_default_model.return_value = "test-model" - mgr = SubagentManager(provider=provider, workspace=MagicMock(), bus=bus) + mgr = SubagentManager( + provider=provider, + workspace=MagicMock(), + bus=bus, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + ) cancelled = asyncio.Event() @@ -214,7 +223,12 @@ class TestSubagentCancellation: bus = MessageBus() provider = MagicMock() provider.get_default_model.return_value = "test-model" - mgr = SubagentManager(provider=provider, workspace=MagicMock(), bus=bus) + mgr = SubagentManager( + provider=provider, + workspace=MagicMock(), + bus=bus, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + ) assert await mgr.cancel_by_session("nonexistent") == 0 @pytest.mark.asyncio @@ -236,19 +250,24 @@ class TestSubagentCancellation: if call_count["n"] == 1: return LLMResponse( content="thinking", - tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={})], + tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={"path": "."})], reasoning_content="hidden reasoning", thinking_blocks=[{"type": "thinking", "thinking": "step"}], ) captured_second_call[:] = messages return LLMResponse(content="done", tool_calls=[]) provider.chat_with_retry = scripted_chat_with_retry - mgr = SubagentManager(provider=provider, workspace=tmp_path, bus=bus) + mgr = SubagentManager( + provider=provider, + workspace=tmp_path, + bus=bus, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + ) - async def fake_execute(self, name, arguments): + async def fake_execute(self, **kwargs): return "tool result" - monkeypatch.setattr("nanobot.agent.tools.registry.ToolRegistry.execute", fake_execute) + monkeypatch.setattr("nanobot.agent.tools.filesystem.ListDirTool.execute", fake_execute) await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"}) @@ -273,6 +292,7 @@ class TestSubagentCancellation: provider=provider, workspace=tmp_path, bus=bus, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, exec_config=ExecToolConfig(enable=False), ) mgr._announce_result = AsyncMock() @@ -304,20 +324,25 @@ class TestSubagentCancellation: provider.get_default_model.return_value = "test-model" provider.chat_with_retry = AsyncMock(return_value=LLMResponse( content="thinking", - tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={})], + tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={"path": "."})], )) - mgr = SubagentManager(provider=provider, workspace=tmp_path, bus=bus) + mgr = SubagentManager( + provider=provider, + workspace=tmp_path, + bus=bus, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + ) mgr._announce_result = AsyncMock() calls = {"n": 0} - async def fake_execute(self, name, arguments): + async def fake_execute(self, **kwargs): calls["n"] += 1 if calls["n"] == 1: return "first result" raise RuntimeError("boom") - monkeypatch.setattr("nanobot.agent.tools.registry.ToolRegistry.execute", fake_execute) + monkeypatch.setattr("nanobot.agent.tools.filesystem.ListDirTool.execute", fake_execute) await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"}) @@ -340,15 +365,20 @@ class TestSubagentCancellation: provider.get_default_model.return_value = "test-model" provider.chat_with_retry = AsyncMock(return_value=LLMResponse( content="thinking", - tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={})], + tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={"path": "."})], )) - mgr = SubagentManager(provider=provider, workspace=tmp_path, bus=bus) + mgr = SubagentManager( + provider=provider, + workspace=tmp_path, + bus=bus, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + ) mgr._announce_result = AsyncMock() started = asyncio.Event() cancelled = asyncio.Event() - async def fake_execute(self, name, arguments): + async def fake_execute(self, **kwargs): started.set() try: await asyncio.sleep(60) @@ -356,7 +386,7 @@ class TestSubagentCancellation: cancelled.set() raise - monkeypatch.setattr("nanobot.agent.tools.registry.ToolRegistry.execute", fake_execute) + monkeypatch.setattr("nanobot.agent.tools.filesystem.ListDirTool.execute", fake_execute) task = asyncio.create_task( mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"}) @@ -364,7 +394,7 @@ class TestSubagentCancellation: mgr._running_tasks["sub-1"] = task mgr._session_tasks["test:c1"] = {"sub-1"} - await started.wait() + await asyncio.wait_for(started.wait(), timeout=1.0) count = await mgr.cancel_by_session("test:c1") diff --git a/tests/channels/test_discord_channel.py b/tests/channels/test_discord_channel.py index d352c788..845c03c5 100644 --- a/tests/channels/test_discord_channel.py +++ b/tests/channels/test_discord_channel.py @@ -594,7 +594,7 @@ async def test_send_stops_typing_after_send() -> None: typing_channel.typing_enter_hook = slow_typing await channel._start_typing(typing_channel) - await start.wait() + await asyncio.wait_for(start.wait(), timeout=1.0) await channel.send(OutboundMessage(channel="discord", chat_id="123", content="hello")) release.set() @@ -614,7 +614,7 @@ async def test_send_stops_typing_after_send() -> None: typing_channel.typing_enter_hook = slow_typing_progress await channel._start_typing(typing_channel) - await start.wait() + await asyncio.wait_for(start.wait(), timeout=1.0) await channel.send( OutboundMessage( @@ -665,7 +665,7 @@ async def test_start_typing_uses_typing_context_when_trigger_typing_missing() -> typing_channel = _NoTriggerChannel(channel_id=123) await channel._start_typing(typing_channel) # type: ignore[arg-type] - await entered.wait() + await asyncio.wait_for(entered.wait(), timeout=1.0) assert "123" in channel._typing_tasks diff --git a/tests/providers/test_litellm_kwargs.py b/tests/providers/test_litellm_kwargs.py index 62fb0a2c..cc8347f0 100644 --- a/tests/providers/test_litellm_kwargs.py +++ b/tests/providers/test_litellm_kwargs.py @@ -8,6 +8,7 @@ Validates that: from __future__ import annotations +import asyncio from types import SimpleNamespace from unittest.mock import AsyncMock, patch @@ -53,6 +54,15 @@ def _fake_tool_call_response() -> SimpleNamespace: return SimpleNamespace(choices=[choice], usage=usage) +class _StalledStream: + def __aiter__(self): + return self + + async def __anext__(self): + await asyncio.sleep(3600) + raise StopAsyncIteration + + def test_openrouter_spec_is_gateway() -> None: spec = find_by_name("openrouter") assert spec is not None @@ -214,3 +224,54 @@ def test_openai_model_passthrough() -> None: spec=spec, ) assert provider.get_default_model() == "gpt-4o" + + +def test_openai_compat_strips_message_level_reasoning_fields() -> None: + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + provider = OpenAICompatProvider() + + sanitized = provider._sanitize_messages([ + { + "role": "assistant", + "content": "done", + "reasoning_content": "hidden", + "extra_content": {"debug": True}, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "fn", "arguments": "{}"}, + "extra_content": {"google": {"thought_signature": "sig"}}, + } + ], + } + ]) + + assert "reasoning_content" not in sanitized[0] + assert "extra_content" not in sanitized[0] + assert sanitized[0]["tool_calls"][0]["extra_content"] == {"google": {"thought_signature": "sig"}} + + +@pytest.mark.asyncio +async def test_openai_compat_stream_watchdog_returns_error_on_stall(monkeypatch) -> None: + monkeypatch.setenv("NANOBOT_STREAM_IDLE_TIMEOUT_S", "0") + mock_create = AsyncMock(return_value=_StalledStream()) + spec = find_by_name("openai") + + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient: + client_instance = MockClient.return_value + client_instance.chat.completions.create = mock_create + + provider = OpenAICompatProvider( + api_key="sk-test-key", + default_model="gpt-4o", + spec=spec, + ) + result = await provider.chat_stream( + messages=[{"role": "user", "content": "hello"}], + model="gpt-4o", + ) + + assert result.finish_reason == "error" + assert result.content is not None + assert "stream stalled" in result.content diff --git a/tests/providers/test_provider_retry.py b/tests/providers/test_provider_retry.py index d732054d..6b5c8d8d 100644 --- a/tests/providers/test_provider_retry.py +++ b/tests/providers/test_provider_retry.py @@ -211,3 +211,32 @@ async def test_image_fallback_without_meta_uses_default_placeholder() -> None: content = msg.get("content") if isinstance(content, list): assert any("[image omitted]" in (b.get("text") or "") for b in content) + + +@pytest.mark.asyncio +async def test_chat_with_retry_uses_retry_after_and_emits_wait_progress(monkeypatch) -> None: + provider = ScriptedProvider([ + LLMResponse(content="429 rate limit, retry after 7s", finish_reason="error"), + LLMResponse(content="ok"), + ]) + delays: list[float] = [] + progress: list[str] = [] + + async def _fake_sleep(delay: float) -> None: + delays.append(delay) + + async def _progress(msg: str) -> None: + progress.append(msg) + + monkeypatch.setattr("nanobot.providers.base.asyncio.sleep", _fake_sleep) + + response = await provider.chat_with_retry( + messages=[{"role": "user", "content": "hello"}], + on_retry_wait=_progress, + ) + + assert response.content == "ok" + assert delays == [7.0] + assert progress and "7s" in progress[0] + + diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index 28666f05..9c132025 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -196,7 +196,7 @@ async def test_execute_re_raises_external_cancellation() -> None: wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool), timeout=10) task = asyncio.create_task(wrapper.execute()) - await started.wait() + await asyncio.wait_for(started.wait(), timeout=1.0) task.cancel() From a37bc26ed3f384464ce1719a27b51f73d509f349 Mon Sep 17 00:00:00 2001 From: RongLei Date: Tue, 31 Mar 2026 23:36:37 +0800 Subject: [PATCH 45/91] fix: restore GitHub Copilot auth flow Implement the real GitHub device flow and Copilot token exchange for the GitHub Copilot provider. Also route github-copilot models through a dedicated backend and strip the provider prefix before API requests. Add focused regression coverage for provider wiring and model normalization. Generated with GitHub Copilot, GPT-5.4. --- nanobot/cli/commands.py | 31 ++- nanobot/providers/__init__.py | 3 + nanobot/providers/github_copilot_provider.py | 207 +++++++++++++++++++ nanobot/providers/registry.py | 5 +- tests/cli/test_commands.py | 40 ++++ 5 files changed, 265 insertions(+), 21 deletions(-) create mode 100644 nanobot/providers/github_copilot_provider.py diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 7f7d24f3..49521aa1 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -415,6 +415,9 @@ def _make_provider(config: Config): api_base=p.api_base, default_model=model, ) + elif backend == "github_copilot": + from nanobot.providers.github_copilot_provider import GitHubCopilotProvider + provider = GitHubCopilotProvider(default_model=model) elif backend == "anthropic": from nanobot.providers.anthropic_provider import AnthropicProvider provider = AnthropicProvider( @@ -1289,26 +1292,16 @@ def _login_openai_codex() -> None: @_register_login("github_copilot") def _login_github_copilot() -> None: - import asyncio - - from openai import AsyncOpenAI - - console.print("[cyan]Starting GitHub Copilot device flow...[/cyan]\n") - - async def _trigger(): - client = AsyncOpenAI( - api_key="dummy", - base_url="https://api.githubcopilot.com", - ) - await client.chat.completions.create( - model="gpt-4o", - messages=[{"role": "user", "content": "hi"}], - max_tokens=1, - ) - try: - asyncio.run(_trigger()) - console.print("[green]✓ Authenticated with GitHub Copilot[/green]") + from nanobot.providers.github_copilot_provider import login_github_copilot + + console.print("[cyan]Starting GitHub Copilot device flow...[/cyan]\n") + token = login_github_copilot( + print_fn=lambda s: console.print(s), + prompt_fn=lambda s: typer.prompt(s), + ) + account = token.account_id or "GitHub" + console.print(f"[green]✓ Authenticated with GitHub Copilot[/green] [dim]{account}[/dim]") except Exception as e: console.print(f"[red]Authentication error: {e}[/red]") raise typer.Exit(1) diff --git a/nanobot/providers/__init__.py b/nanobot/providers/__init__.py index 0e259e6f..ce237870 100644 --- a/nanobot/providers/__init__.py +++ b/nanobot/providers/__init__.py @@ -13,6 +13,7 @@ __all__ = [ "AnthropicProvider", "OpenAICompatProvider", "OpenAICodexProvider", + "GitHubCopilotProvider", "AzureOpenAIProvider", ] @@ -20,12 +21,14 @@ _LAZY_IMPORTS = { "AnthropicProvider": ".anthropic_provider", "OpenAICompatProvider": ".openai_compat_provider", "OpenAICodexProvider": ".openai_codex_provider", + "GitHubCopilotProvider": ".github_copilot_provider", "AzureOpenAIProvider": ".azure_openai_provider", } if TYPE_CHECKING: from nanobot.providers.anthropic_provider import AnthropicProvider from nanobot.providers.azure_openai_provider import AzureOpenAIProvider + from nanobot.providers.github_copilot_provider import GitHubCopilotProvider from nanobot.providers.openai_compat_provider import OpenAICompatProvider from nanobot.providers.openai_codex_provider import OpenAICodexProvider diff --git a/nanobot/providers/github_copilot_provider.py b/nanobot/providers/github_copilot_provider.py new file mode 100644 index 00000000..eb8b922a --- /dev/null +++ b/nanobot/providers/github_copilot_provider.py @@ -0,0 +1,207 @@ +"""GitHub Copilot OAuth-backed provider.""" + +from __future__ import annotations + +import time +import webbrowser +from collections.abc import Callable + +import httpx +from oauth_cli_kit.models import OAuthToken +from oauth_cli_kit.storage import FileTokenStorage + +from nanobot.providers.openai_compat_provider import OpenAICompatProvider + +DEFAULT_GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code" +DEFAULT_GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token" +DEFAULT_GITHUB_USER_URL = "https://api.github.com/user" +DEFAULT_COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token" +DEFAULT_COPILOT_BASE_URL = "https://api.githubcopilot.com" +GITHUB_COPILOT_CLIENT_ID = "Iv1.b507a08c87ecfe98" +GITHUB_COPILOT_SCOPE = "read:user" +TOKEN_FILENAME = "github-copilot.json" +TOKEN_APP_NAME = "nanobot" +USER_AGENT = "nanobot/0.1" +EDITOR_VERSION = "vscode/1.99.0" +EDITOR_PLUGIN_VERSION = "copilot-chat/0.26.0" +_EXPIRY_SKEW_SECONDS = 60 +_LONG_LIVED_TOKEN_SECONDS = 315360000 + + +def _storage() -> FileTokenStorage: + return FileTokenStorage( + token_filename=TOKEN_FILENAME, + app_name=TOKEN_APP_NAME, + import_codex_cli=False, + ) + + +def _copilot_headers(token: str) -> dict[str, str]: + return { + "Authorization": f"token {token}", + "Accept": "application/json", + "User-Agent": USER_AGENT, + "Editor-Version": EDITOR_VERSION, + "Editor-Plugin-Version": EDITOR_PLUGIN_VERSION, + } + + +def _load_github_token() -> OAuthToken | None: + token = _storage().load() + if not token or not token.access: + return None + return token + + +def get_github_copilot_login_status() -> OAuthToken | None: + """Return the persisted GitHub OAuth token if available.""" + return _load_github_token() + + +def login_github_copilot( + print_fn: Callable[[str], None] | None = None, + prompt_fn: Callable[[str], str] | None = None, +) -> OAuthToken: + """Run GitHub device flow and persist the GitHub OAuth token used for Copilot.""" + del prompt_fn + printer = print_fn or print + timeout = httpx.Timeout(20.0, connect=20.0) + + with httpx.Client(timeout=timeout, follow_redirects=True, trust_env=True) as client: + response = client.post( + DEFAULT_GITHUB_DEVICE_CODE_URL, + headers={"Accept": "application/json", "User-Agent": USER_AGENT}, + data={"client_id": GITHUB_COPILOT_CLIENT_ID, "scope": GITHUB_COPILOT_SCOPE}, + ) + response.raise_for_status() + payload = response.json() + + device_code = str(payload["device_code"]) + user_code = str(payload["user_code"]) + verify_url = str(payload.get("verification_uri") or payload.get("verification_uri_complete") or "") + verify_complete = str(payload.get("verification_uri_complete") or verify_url) + interval = max(1, int(payload.get("interval") or 5)) + expires_in = int(payload.get("expires_in") or 900) + + printer(f"Open: {verify_url}") + printer(f"Code: {user_code}") + if verify_complete: + try: + webbrowser.open(verify_complete) + except Exception: + pass + + deadline = time.time() + expires_in + current_interval = interval + access_token = None + token_expires_in = _LONG_LIVED_TOKEN_SECONDS + while time.time() < deadline: + poll = client.post( + DEFAULT_GITHUB_ACCESS_TOKEN_URL, + headers={"Accept": "application/json", "User-Agent": USER_AGENT}, + data={ + "client_id": GITHUB_COPILOT_CLIENT_ID, + "device_code": device_code, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + }, + ) + poll.raise_for_status() + poll_payload = poll.json() + + access_token = poll_payload.get("access_token") + if access_token: + token_expires_in = int(poll_payload.get("expires_in") or _LONG_LIVED_TOKEN_SECONDS) + break + + error = poll_payload.get("error") + if error == "authorization_pending": + time.sleep(current_interval) + continue + if error == "slow_down": + current_interval += 5 + time.sleep(current_interval) + continue + if error == "expired_token": + raise RuntimeError("GitHub device code expired. Please run login again.") + if error == "access_denied": + raise RuntimeError("GitHub device flow was denied.") + if error: + desc = poll_payload.get("error_description") or error + raise RuntimeError(str(desc)) + time.sleep(current_interval) + else: + raise RuntimeError("GitHub device flow timed out.") + + user = client.get( + DEFAULT_GITHUB_USER_URL, + headers={ + "Authorization": f"Bearer {access_token}", + "Accept": "application/vnd.github+json", + "User-Agent": USER_AGENT, + }, + ) + user.raise_for_status() + user_payload = user.json() + account_id = user_payload.get("login") or str(user_payload.get("id") or "") or None + + expires_ms = int((time.time() + token_expires_in) * 1000) + token = OAuthToken( + access=str(access_token), + refresh="", + expires=expires_ms, + account_id=str(account_id) if account_id else None, + ) + _storage().save(token) + return token + + +class GitHubCopilotProvider(OpenAICompatProvider): + """Provider that exchanges a stored GitHub OAuth token for Copilot access tokens.""" + + def __init__(self, default_model: str = "github-copilot/gpt-4.1"): + from nanobot.providers.registry import find_by_name + + self._copilot_access_token: str | None = None + self._copilot_expires_at: float = 0.0 + super().__init__( + api_key=self._get_copilot_access_token, + api_base=DEFAULT_COPILOT_BASE_URL, + default_model=default_model, + extra_headers={ + "Editor-Version": EDITOR_VERSION, + "Editor-Plugin-Version": EDITOR_PLUGIN_VERSION, + "User-Agent": USER_AGENT, + }, + spec=find_by_name("github_copilot"), + ) + + async def _get_copilot_access_token(self) -> str: + now = time.time() + if self._copilot_access_token and now < self._copilot_expires_at - _EXPIRY_SKEW_SECONDS: + return self._copilot_access_token + + github_token = _load_github_token() + if not github_token or not github_token.access: + raise RuntimeError("GitHub Copilot is not logged in. Run: nanobot provider login github-copilot") + + timeout = httpx.Timeout(20.0, connect=20.0) + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, trust_env=True) as client: + response = await client.get( + DEFAULT_COPILOT_TOKEN_URL, + headers=_copilot_headers(github_token.access), + ) + response.raise_for_status() + payload = response.json() + + token = payload.get("token") + if not token: + raise RuntimeError("GitHub Copilot token exchange returned no token.") + + expires_at = payload.get("expires_at") + if isinstance(expires_at, (int, float)): + self._copilot_expires_at = float(expires_at) + else: + refresh_in = payload.get("refresh_in") or 1500 + self._copilot_expires_at = time.time() + int(refresh_in) + self._copilot_access_token = str(token) + return self._copilot_access_token diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 5644fc51..8435005e 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -34,7 +34,7 @@ class ProviderSpec: display_name: str = "" # shown in `nanobot status` # which provider implementation to use - # "openai_compat" | "anthropic" | "azure_openai" | "openai_codex" + # "openai_compat" | "anthropic" | "azure_openai" | "openai_codex" | "github_copilot" backend: str = "openai_compat" # extra env vars, e.g. (("ZHIPUAI_API_KEY", "{api_key}"),) @@ -218,8 +218,9 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( keywords=("github_copilot", "copilot"), env_key="", display_name="Github Copilot", - backend="openai_compat", + backend="github_copilot", default_api_base="https://api.githubcopilot.com", + strip_model_prefix=True, is_oauth=True, ), # DeepSeek: OpenAI-compatible at api.deepseek.com diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 735c02a5..b9869e74 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -317,6 +317,46 @@ def test_openai_compat_provider_passes_model_through(): assert provider.get_default_model() == "github-copilot/gpt-5.3-codex" +def test_make_provider_uses_github_copilot_backend(): + from nanobot.cli.commands import _make_provider + from nanobot.config.schema import Config + + config = Config.model_validate( + { + "agents": { + "defaults": { + "provider": "github-copilot", + "model": "github-copilot/gpt-4.1", + } + } + } + ) + + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + provider = _make_provider(config) + + assert provider.__class__.__name__ == "GitHubCopilotProvider" + + +def test_github_copilot_provider_strips_prefixed_model_name(): + from nanobot.providers.github_copilot_provider import GitHubCopilotProvider + + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + provider = GitHubCopilotProvider(default_model="github-copilot/gpt-5.1") + + kwargs = provider._build_kwargs( + messages=[{"role": "user", "content": "hi"}], + tools=None, + model="github-copilot/gpt-5.1", + max_tokens=16, + temperature=0.1, + reasoning_effort=None, + tool_choice=None, + ) + + assert kwargs["model"] == "gpt-5.1" + + def test_openai_codex_strip_prefix_supports_hyphen_and_underscore(): assert _strip_model_prefix("openai-codex/gpt-5.1-codex") == "gpt-5.1-codex" assert _strip_model_prefix("openai_codex/gpt-5.1-codex") == "gpt-5.1-codex" From c5f09973817b6de5461aeca6b6f4fe60ebf1cac1 Mon Sep 17 00:00:00 2001 From: RongLei Date: Wed, 1 Apr 2026 21:43:49 +0800 Subject: [PATCH 46/91] fix: refresh copilot token before requests Address PR review feedback by avoiding an async method reference as the OpenAI client api_key. Initialize the client with a placeholder key, refresh the Copilot token before each chat/chat_stream call, and update the runtime client api_key before dispatch. Add a regression test that verifies the client api_key is refreshed to a real string before chat requests. Generated with GitHub Copilot, GPT-5.4. --- nanobot/providers/github_copilot_provider.py | 52 +++++++++++++++++++- tests/cli/test_commands.py | 29 +++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/nanobot/providers/github_copilot_provider.py b/nanobot/providers/github_copilot_provider.py index eb8b922a..8d50006a 100644 --- a/nanobot/providers/github_copilot_provider.py +++ b/nanobot/providers/github_copilot_provider.py @@ -164,7 +164,7 @@ class GitHubCopilotProvider(OpenAICompatProvider): self._copilot_access_token: str | None = None self._copilot_expires_at: float = 0.0 super().__init__( - api_key=self._get_copilot_access_token, + api_key="no-key", api_base=DEFAULT_COPILOT_BASE_URL, default_model=default_model, extra_headers={ @@ -205,3 +205,53 @@ class GitHubCopilotProvider(OpenAICompatProvider): self._copilot_expires_at = time.time() + int(refresh_in) self._copilot_access_token = str(token) return self._copilot_access_token + + async def _refresh_client_api_key(self) -> str: + token = await self._get_copilot_access_token() + self.api_key = token + self._client.api_key = token + return token + + async def chat( + self, + messages: list[dict[str, object]], + tools: list[dict[str, object]] | None = None, + model: str | None = None, + max_tokens: int = 4096, + temperature: float = 0.7, + reasoning_effort: str | None = None, + tool_choice: str | dict[str, object] | None = None, + ): + await self._refresh_client_api_key() + return await super().chat( + messages=messages, + tools=tools, + model=model, + max_tokens=max_tokens, + temperature=temperature, + reasoning_effort=reasoning_effort, + tool_choice=tool_choice, + ) + + async def chat_stream( + self, + messages: list[dict[str, object]], + tools: list[dict[str, object]] | None = None, + model: str | None = None, + max_tokens: int = 4096, + temperature: float = 0.7, + reasoning_effort: str | None = None, + tool_choice: str | dict[str, object] | None = None, + on_content_delta: Callable[[str], None] | None = None, + ): + await self._refresh_client_api_key() + return await super().chat_stream( + messages=messages, + tools=tools, + model=model, + max_tokens=max_tokens, + temperature=temperature, + reasoning_effort=reasoning_effort, + tool_choice=tool_choice, + on_content_delta=on_content_delta, + ) diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index b9869e74..0f6ff817 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -357,6 +357,35 @@ def test_github_copilot_provider_strips_prefixed_model_name(): assert kwargs["model"] == "gpt-5.1" +@pytest.mark.asyncio +async def test_github_copilot_provider_refreshes_client_api_key_before_chat(): + from nanobot.providers.github_copilot_provider import GitHubCopilotProvider + + mock_client = MagicMock() + mock_client.api_key = "no-key" + mock_client.chat.completions.create = AsyncMock(return_value={ + "choices": [{"message": {"content": "ok"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + }) + + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI", return_value=mock_client): + provider = GitHubCopilotProvider(default_model="github-copilot/gpt-5.1") + + provider._get_copilot_access_token = AsyncMock(return_value="copilot-access-token") + + response = await provider.chat( + messages=[{"role": "user", "content": "hi"}], + model="github-copilot/gpt-5.1", + max_tokens=16, + temperature=0.1, + ) + + assert response.content == "ok" + assert provider._client.api_key == "copilot-access-token" + provider._get_copilot_access_token.assert_awaited_once() + mock_client.chat.completions.create.assert_awaited_once() + + def test_openai_codex_strip_prefix_supports_hyphen_and_underscore(): assert _strip_model_prefix("openai-codex/gpt-5.1-codex") == "gpt-5.1-codex" assert _strip_model_prefix("openai_codex/gpt-5.1-codex") == "gpt-5.1-codex" From 2ec68582eb78113be3dfce4b1bf3165668750af6 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Wed, 1 Apr 2026 19:37:08 +0000 Subject: [PATCH 47/91] fix(sdk): route github copilot through oauth provider --- nanobot/nanobot.py | 4 ++++ tests/test_nanobot_facade.py | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/nanobot/nanobot.py b/nanobot/nanobot.py index 13768845..84fb7093 100644 --- a/nanobot/nanobot.py +++ b/nanobot/nanobot.py @@ -135,6 +135,10 @@ def _make_provider(config: Any) -> Any: from nanobot.providers.openai_codex_provider import OpenAICodexProvider provider = OpenAICodexProvider(default_model=model) + elif backend == "github_copilot": + from nanobot.providers.github_copilot_provider import GitHubCopilotProvider + + provider = GitHubCopilotProvider(default_model=model) elif backend == "azure_openai": from nanobot.providers.azure_openai_provider import AzureOpenAIProvider diff --git a/tests/test_nanobot_facade.py b/tests/test_nanobot_facade.py index 9d0d8a17..9ad9c5db 100644 --- a/tests/test_nanobot_facade.py +++ b/tests/test_nanobot_facade.py @@ -125,6 +125,27 @@ def test_workspace_override(tmp_path): assert bot._loop.workspace == custom_ws +def test_sdk_make_provider_uses_github_copilot_backend(): + from nanobot.config.schema import Config + from nanobot.nanobot import _make_provider + + config = Config.model_validate( + { + "agents": { + "defaults": { + "provider": "github-copilot", + "model": "github-copilot/gpt-4.1", + } + } + } + ) + + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + provider = _make_provider(config) + + assert provider.__class__.__name__ == "GitHubCopilotProvider" + + @pytest.mark.asyncio async def test_run_custom_session_key(tmp_path): from nanobot.bus.events import OutboundMessage From 7e719f41cc7b4c4edf9f5900ff25d91b134e26d2 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Wed, 1 Apr 2026 19:43:41 +0000 Subject: [PATCH 48/91] test(providers): cover github copilot lazy export --- tests/providers/test_providers_init.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/providers/test_providers_init.py b/tests/providers/test_providers_init.py index 32cbab47..d6912b43 100644 --- a/tests/providers/test_providers_init.py +++ b/tests/providers/test_providers_init.py @@ -11,6 +11,7 @@ def test_importing_providers_package_is_lazy(monkeypatch) -> None: monkeypatch.delitem(sys.modules, "nanobot.providers.anthropic_provider", raising=False) monkeypatch.delitem(sys.modules, "nanobot.providers.openai_compat_provider", raising=False) monkeypatch.delitem(sys.modules, "nanobot.providers.openai_codex_provider", raising=False) + monkeypatch.delitem(sys.modules, "nanobot.providers.github_copilot_provider", raising=False) monkeypatch.delitem(sys.modules, "nanobot.providers.azure_openai_provider", raising=False) providers = importlib.import_module("nanobot.providers") @@ -18,6 +19,7 @@ def test_importing_providers_package_is_lazy(monkeypatch) -> None: assert "nanobot.providers.anthropic_provider" not in sys.modules assert "nanobot.providers.openai_compat_provider" not in sys.modules assert "nanobot.providers.openai_codex_provider" not in sys.modules + assert "nanobot.providers.github_copilot_provider" not in sys.modules assert "nanobot.providers.azure_openai_provider" not in sys.modules assert providers.__all__ == [ "LLMProvider", @@ -25,6 +27,7 @@ def test_importing_providers_package_is_lazy(monkeypatch) -> None: "AnthropicProvider", "OpenAICompatProvider", "OpenAICodexProvider", + "GitHubCopilotProvider", "AzureOpenAIProvider", ] From 6973bfff24b66d81bcb8d673ee6b745dfdbd0f4f Mon Sep 17 00:00:00 2001 From: WormW Date: Wed, 25 Mar 2026 17:37:56 +0800 Subject: [PATCH 49/91] fix(agent): message tool incorrectly replies to original chat when targeting different chat_id When the message tool is used to send a message to a different chat_id than the current conversation, it was incorrectly including the default message_id from the original context. This caused channels like Feishu to send the message as a reply to the original chat instead of creating a new message in the target chat. Changes: - Only use default message_id when chat_id matches the default context - When targeting a different chat, set message_id to None to avoid unintended reply behavior --- nanobot/agent/tools/message.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py index c8d50cf1..efbadca1 100644 --- a/nanobot/agent/tools/message.py +++ b/nanobot/agent/tools/message.py @@ -86,7 +86,13 @@ class MessageTool(Tool): ) -> 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 + # Only use default message_id if chat_id matches the default context. + # If targeting a different chat, don't reply to the original message. + if chat_id == self._default_chat_id: + message_id = message_id or self._default_message_id + else: + # Targeting a different chat - don't use default message_id + message_id = None if not channel or not chat_id: return "Error: No target channel/chat specified" @@ -101,7 +107,7 @@ class MessageTool(Tool): media=media or [], metadata={ "message_id": message_id, - }, + } if message_id else {}, ) try: From ddc9fc4fd286025aebaab5fb3f2f032a18ed2478 Mon Sep 17 00:00:00 2001 From: WormW Date: Wed, 1 Apr 2026 12:32:15 +0800 Subject: [PATCH 50/91] fix: also check channel match before inheriting default message_id Different channels could theoretically share the same chat_id. Check both channel and chat_id to avoid cross-channel reply issues. Co-authored-by: layla <111667698+04cb@users.noreply.github.com> --- nanobot/agent/tools/message.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py index efbadca1..3ac81324 100644 --- a/nanobot/agent/tools/message.py +++ b/nanobot/agent/tools/message.py @@ -86,12 +86,14 @@ class MessageTool(Tool): ) -> str: channel = channel or self._default_channel chat_id = chat_id or self._default_chat_id - # Only use default message_id if chat_id matches the default context. - # If targeting a different chat, don't reply to the original message. - if chat_id == self._default_chat_id: + # Only inherit default message_id when targeting the same channel+chat. + # Cross-chat sends must not carry the original message_id, because + # some channels (e.g. Feishu) use it to determine the target + # conversation via their Reply API, which would route the message + # to the wrong chat entirely. + if channel == self._default_channel and chat_id == self._default_chat_id: message_id = message_id or self._default_message_id else: - # Targeting a different chat - don't use default message_id message_id = None if not channel or not chat_id: From bc2e474079a38f0f68039a8767cff088682fd6c0 Mon Sep 17 00:00:00 2001 From: "zhangxiaoyu.york" Date: Tue, 31 Mar 2026 23:27:39 +0800 Subject: [PATCH 51/91] Fix ExecTool to block root directory paths when restrict_to_workspace is enabled --- nanobot/agent/tools/shell.py | 4 +++- tests/tools/test_tool_validation.py | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index ed552b33..b051edff 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -186,7 +186,9 @@ class ExecTool(Tool): @staticmethod def _extract_absolute_paths(command: str) -> list[str]: - win_paths = re.findall(r"[A-Za-z]:\\[^\s\"'|><;]+", command) # Windows: C:\... + # Windows: match drive-root paths like `C:\` as well as `C:\path\to\file` + # NOTE: `*` is required so `C:\` (nothing after the slash) is still extracted. + win_paths = re.findall(r"[A-Za-z]:\\[^\s\"'|><;]*", command) posix_paths = re.findall(r"(?:^|[\s|>'\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only home_paths = re.findall(r"(?:^|[\s|>'\"])(~[^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~ return win_paths + posix_paths + home_paths diff --git a/tests/tools/test_tool_validation.py b/tests/tools/test_tool_validation.py index a95418fe..af467531 100644 --- a/tests/tools/test_tool_validation.py +++ b/tests/tools/test_tool_validation.py @@ -95,6 +95,14 @@ def test_exec_extract_absolute_paths_keeps_full_windows_path() -> None: assert paths == [r"C:\user\workspace\txt"] +def test_exec_extract_absolute_paths_captures_windows_drive_root_path() -> None: + """Windows drive root paths like `E:\\` must be extracted for workspace guarding.""" + # Note: raw strings cannot end with a single backslash. + cmd = "dir E:\\" + paths = ExecTool._extract_absolute_paths(cmd) + assert paths == ["E:\\"] + + def test_exec_extract_absolute_paths_ignores_relative_posix_segments() -> None: cmd = ".venv/bin/python script.py" paths = ExecTool._extract_absolute_paths(cmd) From 485c75e065808aa3d27bb35805d782d3365a5794 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Wed, 1 Apr 2026 19:52:54 +0000 Subject: [PATCH 52/91] test(exec): verify windows drive-root workspace guard --- tests/tools/test_tool_validation.py | 39 +++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/tools/test_tool_validation.py b/tests/tools/test_tool_validation.py index af467531..98a3dc90 100644 --- a/tests/tools/test_tool_validation.py +++ b/tests/tools/test_tool_validation.py @@ -142,6 +142,45 @@ def test_exec_guard_blocks_quoted_home_path_outside_workspace(tmp_path) -> None: assert error == "Error: Command blocked by safety guard (path outside working dir)" +def test_exec_guard_blocks_windows_drive_root_outside_workspace(monkeypatch) -> None: + import nanobot.agent.tools.shell as shell_mod + + class FakeWindowsPath: + def __init__(self, raw: str) -> None: + self.raw = raw.rstrip("\\") + ("\\" if raw.endswith("\\") else "") + + def resolve(self) -> "FakeWindowsPath": + return self + + def expanduser(self) -> "FakeWindowsPath": + return self + + def is_absolute(self) -> bool: + return len(self.raw) >= 3 and self.raw[1:3] == ":\\" + + @property + def parents(self) -> list["FakeWindowsPath"]: + if not self.is_absolute(): + return [] + trimmed = self.raw.rstrip("\\") + if len(trimmed) <= 2: + return [] + idx = trimmed.rfind("\\") + if idx <= 2: + return [FakeWindowsPath(trimmed[:2] + "\\")] + parent = FakeWindowsPath(trimmed[:idx]) + return [parent, *parent.parents] + + def __eq__(self, other: object) -> bool: + return isinstance(other, FakeWindowsPath) and self.raw.lower() == other.raw.lower() + + monkeypatch.setattr(shell_mod, "Path", FakeWindowsPath) + + tool = ExecTool(restrict_to_workspace=True) + error = tool._guard_command("dir E:\\", "E:\\workspace") + assert error == "Error: Command blocked by safety guard (path outside working dir)" + + # --- cast_params tests --- From 05fe73947f219be405be57d9a27eb97e00fa4953 Mon Sep 17 00:00:00 2001 From: Tejas1Koli Date: Wed, 1 Apr 2026 00:51:49 +0530 Subject: [PATCH 53/91] fix(providers): only apply cache_control for Claude models on OpenRouter --- nanobot/providers/openai_compat_provider.py | 117 +++++++++++++------- 1 file changed, 79 insertions(+), 38 deletions(-) diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index 397b8e79..a033b44e 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -18,10 +18,17 @@ from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest if TYPE_CHECKING: from nanobot.providers.registry import ProviderSpec -_ALLOWED_MSG_KEYS = frozenset({ - "role", "content", "tool_calls", "tool_call_id", "name", - "reasoning_content", "extra_content", -}) +_ALLOWED_MSG_KEYS = frozenset( + { + "role", + "content", + "tool_calls", + "tool_call_id", + "name", + "reasoning_content", + "extra_content", + } +) _ALNUM = string.ascii_letters + string.digits _STANDARD_TC_KEYS = frozenset({"id", "type", "index", "function"}) @@ -59,7 +66,9 @@ def _coerce_dict(value: Any) -> dict[str, Any] | None: return None -def _extract_tc_extras(tc: Any) -> tuple[ +def _extract_tc_extras( + tc: Any, +) -> tuple[ dict[str, Any] | None, dict[str, Any] | None, dict[str, Any] | None, @@ -75,14 +84,18 @@ def _extract_tc_extras(tc: Any) -> tuple[ prov = None fn_prov = None if tc_dict is not None: - leftover = {k: v for k, v in tc_dict.items() - if k not in _STANDARD_TC_KEYS and k != "extra_content" and v is not None} + leftover = { + k: v + for k, v in tc_dict.items() + if k not in _STANDARD_TC_KEYS and k != "extra_content" and v is not None + } if leftover: prov = leftover fn = _coerce_dict(tc_dict.get("function")) if fn is not None: - fn_leftover = {k: v for k, v in fn.items() - if k not in _STANDARD_FN_KEYS and v is not None} + fn_leftover = { + k: v for k, v in fn.items() if k not in _STANDARD_FN_KEYS and v is not None + } if fn_leftover: fn_prov = fn_leftover else: @@ -163,9 +176,12 @@ class OpenAICompatProvider(LLMProvider): def _mark(msg: dict[str, Any]) -> dict[str, Any]: content = msg.get("content") if isinstance(content, str): - return {**msg, "content": [ - {"type": "text", "text": content, "cache_control": cache_marker}, - ]} + return { + **msg, + "content": [ + {"type": "text", "text": content, "cache_control": cache_marker}, + ], + } if isinstance(content, list) and content: nc = list(content) nc[-1] = {**nc[-1], "cache_control": cache_marker} @@ -235,7 +251,9 @@ class OpenAICompatProvider(LLMProvider): spec = self._spec if spec and spec.supports_prompt_caching: - messages, tools = self._apply_cache_control(messages, tools) + model_name = model or self.default_model + if any(model_name.lower().startswith(k) for k in ("anthropic/", "claude")): + messages, tools = self._apply_cache_control(messages, tools) if spec and spec.strip_model_prefix: model_name = model_name.split("/")[-1] @@ -348,7 +366,9 @@ class OpenAICompatProvider(LLMProvider): finish_reason=str(response_map.get("finish_reason") or "stop"), usage=self._extract_usage(response_map), ) - return LLMResponse(content="Error: API returned empty choices.", finish_reason="error") + return LLMResponse( + content="Error: API returned empty choices.", finish_reason="error" + ) choice0 = self._maybe_mapping(choices[0]) or {} msg0 = self._maybe_mapping(choice0.get("message")) or {} @@ -378,14 +398,16 @@ class OpenAICompatProvider(LLMProvider): if isinstance(args, str): args = json_repair.loads(args) ec, prov, fn_prov = _extract_tc_extras(tc) - parsed_tool_calls.append(ToolCallRequest( - id=_short_tool_id(), - name=str(fn.get("name") or ""), - arguments=args if isinstance(args, dict) else {}, - extra_content=ec, - provider_specific_fields=prov, - function_provider_specific_fields=fn_prov, - )) + parsed_tool_calls.append( + ToolCallRequest( + id=_short_tool_id(), + name=str(fn.get("name") or ""), + arguments=args if isinstance(args, dict) else {}, + extra_content=ec, + provider_specific_fields=prov, + function_provider_specific_fields=fn_prov, + ) + ) return LLMResponse( content=content, @@ -419,14 +441,16 @@ class OpenAICompatProvider(LLMProvider): if isinstance(args, str): args = json_repair.loads(args) ec, prov, fn_prov = _extract_tc_extras(tc) - tool_calls.append(ToolCallRequest( - id=_short_tool_id(), - name=tc.function.name, - arguments=args, - extra_content=ec, - provider_specific_fields=prov, - function_provider_specific_fields=fn_prov, - )) + tool_calls.append( + ToolCallRequest( + id=_short_tool_id(), + name=tc.function.name, + arguments=args, + extra_content=ec, + provider_specific_fields=prov, + function_provider_specific_fields=fn_prov, + ) + ) return LLMResponse( content=content, @@ -446,10 +470,17 @@ class OpenAICompatProvider(LLMProvider): def _accum_tc(tc: Any, idx_hint: int) -> None: """Accumulate one streaming tool-call delta into *tc_bufs*.""" tc_index: int = _get(tc, "index") if _get(tc, "index") is not None else idx_hint - buf = tc_bufs.setdefault(tc_index, { - "id": "", "name": "", "arguments": "", - "extra_content": None, "prov": None, "fn_prov": None, - }) + buf = tc_bufs.setdefault( + tc_index, + { + "id": "", + "name": "", + "arguments": "", + "extra_content": None, + "prov": None, + "fn_prov": None, + }, + ) tc_id = _get(tc, "id") if tc_id: buf["id"] = str(tc_id) @@ -547,8 +578,13 @@ class OpenAICompatProvider(LLMProvider): tool_choice: str | dict[str, Any] | None = None, ) -> LLMResponse: kwargs = self._build_kwargs( - messages, tools, model, max_tokens, temperature, - reasoning_effort, tool_choice, + messages, + tools, + model, + max_tokens, + temperature, + reasoning_effort, + tool_choice, ) try: return self._parse(await self._client.chat.completions.create(**kwargs)) @@ -567,8 +603,13 @@ class OpenAICompatProvider(LLMProvider): on_content_delta: Callable[[str], Awaitable[None]] | None = None, ) -> LLMResponse: kwargs = self._build_kwargs( - messages, tools, model, max_tokens, temperature, - reasoning_effort, tool_choice, + messages, + tools, + model, + max_tokens, + temperature, + reasoning_effort, + tool_choice, ) kwargs["stream"] = True kwargs["stream_options"] = {"include_usage": True} From 42fa8fa9339b16c031fdb3671c9ee4f3d55d74de Mon Sep 17 00:00:00 2001 From: Tejas1Koli Date: Wed, 1 Apr 2026 10:36:24 +0530 Subject: [PATCH 54/91] fix(providers): only apply cache_control for Claude models on OpenRouter --- nanobot/providers/openai_compat_provider.py | 115 +++++++------------- 1 file changed, 38 insertions(+), 77 deletions(-) diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index a033b44e..967c2197 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -18,17 +18,10 @@ from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest if TYPE_CHECKING: from nanobot.providers.registry import ProviderSpec -_ALLOWED_MSG_KEYS = frozenset( - { - "role", - "content", - "tool_calls", - "tool_call_id", - "name", - "reasoning_content", - "extra_content", - } -) +_ALLOWED_MSG_KEYS = frozenset({ + "role", "content", "tool_calls", "tool_call_id", "name", + "reasoning_content", "extra_content", +}) _ALNUM = string.ascii_letters + string.digits _STANDARD_TC_KEYS = frozenset({"id", "type", "index", "function"}) @@ -66,9 +59,7 @@ def _coerce_dict(value: Any) -> dict[str, Any] | None: return None -def _extract_tc_extras( - tc: Any, -) -> tuple[ +def _extract_tc_extras(tc: Any) -> tuple[ dict[str, Any] | None, dict[str, Any] | None, dict[str, Any] | None, @@ -84,18 +75,14 @@ def _extract_tc_extras( prov = None fn_prov = None if tc_dict is not None: - leftover = { - k: v - for k, v in tc_dict.items() - if k not in _STANDARD_TC_KEYS and k != "extra_content" and v is not None - } + leftover = {k: v for k, v in tc_dict.items() + if k not in _STANDARD_TC_KEYS and k != "extra_content" and v is not None} if leftover: prov = leftover fn = _coerce_dict(tc_dict.get("function")) if fn is not None: - fn_leftover = { - k: v for k, v in fn.items() if k not in _STANDARD_FN_KEYS and v is not None - } + fn_leftover = {k: v for k, v in fn.items() + if k not in _STANDARD_FN_KEYS and v is not None} if fn_leftover: fn_prov = fn_leftover else: @@ -176,12 +163,9 @@ class OpenAICompatProvider(LLMProvider): def _mark(msg: dict[str, Any]) -> dict[str, Any]: content = msg.get("content") if isinstance(content, str): - return { - **msg, - "content": [ - {"type": "text", "text": content, "cache_control": cache_marker}, - ], - } + return {**msg, "content": [ + {"type": "text", "text": content, "cache_control": cache_marker}, + ]} if isinstance(content, list) and content: nc = list(content) nc[-1] = {**nc[-1], "cache_control": cache_marker} @@ -366,9 +350,7 @@ class OpenAICompatProvider(LLMProvider): finish_reason=str(response_map.get("finish_reason") or "stop"), usage=self._extract_usage(response_map), ) - return LLMResponse( - content="Error: API returned empty choices.", finish_reason="error" - ) + return LLMResponse(content="Error: API returned empty choices.", finish_reason="error") choice0 = self._maybe_mapping(choices[0]) or {} msg0 = self._maybe_mapping(choice0.get("message")) or {} @@ -398,16 +380,14 @@ class OpenAICompatProvider(LLMProvider): if isinstance(args, str): args = json_repair.loads(args) ec, prov, fn_prov = _extract_tc_extras(tc) - parsed_tool_calls.append( - ToolCallRequest( - id=_short_tool_id(), - name=str(fn.get("name") or ""), - arguments=args if isinstance(args, dict) else {}, - extra_content=ec, - provider_specific_fields=prov, - function_provider_specific_fields=fn_prov, - ) - ) + parsed_tool_calls.append(ToolCallRequest( + id=_short_tool_id(), + name=str(fn.get("name") or ""), + arguments=args if isinstance(args, dict) else {}, + extra_content=ec, + provider_specific_fields=prov, + function_provider_specific_fields=fn_prov, + )) return LLMResponse( content=content, @@ -441,16 +421,14 @@ class OpenAICompatProvider(LLMProvider): if isinstance(args, str): args = json_repair.loads(args) ec, prov, fn_prov = _extract_tc_extras(tc) - tool_calls.append( - ToolCallRequest( - id=_short_tool_id(), - name=tc.function.name, - arguments=args, - extra_content=ec, - provider_specific_fields=prov, - function_provider_specific_fields=fn_prov, - ) - ) + tool_calls.append(ToolCallRequest( + id=_short_tool_id(), + name=tc.function.name, + arguments=args, + extra_content=ec, + provider_specific_fields=prov, + function_provider_specific_fields=fn_prov, + )) return LLMResponse( content=content, @@ -470,17 +448,10 @@ class OpenAICompatProvider(LLMProvider): def _accum_tc(tc: Any, idx_hint: int) -> None: """Accumulate one streaming tool-call delta into *tc_bufs*.""" tc_index: int = _get(tc, "index") if _get(tc, "index") is not None else idx_hint - buf = tc_bufs.setdefault( - tc_index, - { - "id": "", - "name": "", - "arguments": "", - "extra_content": None, - "prov": None, - "fn_prov": None, - }, - ) + buf = tc_bufs.setdefault(tc_index, { + "id": "", "name": "", "arguments": "", + "extra_content": None, "prov": None, "fn_prov": None, + }) tc_id = _get(tc, "id") if tc_id: buf["id"] = str(tc_id) @@ -578,13 +549,8 @@ class OpenAICompatProvider(LLMProvider): tool_choice: str | dict[str, Any] | None = None, ) -> LLMResponse: kwargs = self._build_kwargs( - messages, - tools, - model, - max_tokens, - temperature, - reasoning_effort, - tool_choice, + messages, tools, model, max_tokens, temperature, + reasoning_effort, tool_choice, ) try: return self._parse(await self._client.chat.completions.create(**kwargs)) @@ -603,13 +569,8 @@ class OpenAICompatProvider(LLMProvider): on_content_delta: Callable[[str], Awaitable[None]] | None = None, ) -> LLMResponse: kwargs = self._build_kwargs( - messages, - tools, - model, - max_tokens, - temperature, - reasoning_effort, - tool_choice, + messages, tools, model, max_tokens, temperature, + reasoning_effort, tool_choice, ) kwargs["stream"] = True kwargs["stream_options"] = {"include_usage": True} @@ -627,4 +588,4 @@ class OpenAICompatProvider(LLMProvider): return self._handle_error(e) def get_default_model(self) -> str: - return self.default_model + return self.default_model \ No newline at end of file From da08dee144bb2d9abf819a56d9a64e67cf76a849 Mon Sep 17 00:00:00 2001 From: chengyongru <61816729+chengyongru@users.noreply.github.com> Date: Tue, 31 Mar 2026 09:48:43 +0800 Subject: [PATCH 55/91] feat(provider): show cache hit rate in /status (#2645) --- nanobot/agent/loop.py | 9 + nanobot/agent/runner.py | 14 +- nanobot/providers/anthropic_provider.py | 4 + nanobot/providers/openai_compat_provider.py | 51 ++++- nanobot/utils/helpers.py | 6 +- tests/agent/test_runner.py | 79 +++++++ tests/cli/test_restart_command.py | 6 +- tests/providers/test_cached_tokens.py | 231 ++++++++++++++++++++ tests/test_build_status.py | 59 +++++ 9 files changed, 445 insertions(+), 14 deletions(-) create mode 100644 tests/providers/test_cached_tokens.py create mode 100644 tests/test_build_status.py diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index a9dc589e..50fef58f 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -97,6 +97,15 @@ class _LoopHook(AgentHook): logger.info("Tool call: {}({})", tc.name, args_str[:200]) self._loop._set_tool_context(self._channel, self._chat_id, self._message_id) + async def after_iteration(self, context: AgentHookContext) -> None: + u = context.usage or {} + logger.debug( + "LLM usage: prompt={} completion={} cached={}", + u.get("prompt_tokens", 0), + u.get("completion_tokens", 0), + u.get("cached_tokens", 0), + ) + def finalize_content(self, context: AgentHookContext, content: str | None) -> str | None: return self._loop._strip_think(content) diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index d6242a6b..4fec539d 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -60,7 +60,7 @@ class AgentRunner: messages = list(spec.initial_messages) final_content: str | None = None tools_used: list[str] = [] - usage = {"prompt_tokens": 0, "completion_tokens": 0} + usage: dict[str, int] = {} error: str | None = None stop_reason = "completed" tool_events: list[dict[str, str]] = [] @@ -92,13 +92,15 @@ class AgentRunner: response = await self.provider.chat_with_retry(**kwargs) raw_usage = response.usage or {} - usage = { - "prompt_tokens": int(raw_usage.get("prompt_tokens", 0) or 0), - "completion_tokens": int(raw_usage.get("completion_tokens", 0) or 0), - } context.response = response - context.usage = usage + context.usage = raw_usage context.tool_calls = list(response.tool_calls) + # Accumulate standard fields into result usage. + usage["prompt_tokens"] = usage.get("prompt_tokens", 0) + int(raw_usage.get("prompt_tokens", 0) or 0) + usage["completion_tokens"] = usage.get("completion_tokens", 0) + int(raw_usage.get("completion_tokens", 0) or 0) + cached = raw_usage.get("cached_tokens") + if cached: + usage["cached_tokens"] = usage.get("cached_tokens", 0) + int(cached) if response.has_tool_calls: if hook.wants_streaming(): diff --git a/nanobot/providers/anthropic_provider.py b/nanobot/providers/anthropic_provider.py index 3c789e73..fabcd565 100644 --- a/nanobot/providers/anthropic_provider.py +++ b/nanobot/providers/anthropic_provider.py @@ -379,6 +379,10 @@ class AnthropicProvider(LLMProvider): val = getattr(response.usage, attr, 0) if val: usage[attr] = val + # Normalize to cached_tokens for downstream consistency. + cache_read = usage.get("cache_read_input_tokens", 0) + if cache_read: + usage["cached_tokens"] = cache_read return LLMResponse( content="".join(content_parts) or None, diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index 967c2197..f89879c9 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -310,6 +310,13 @@ class OpenAICompatProvider(LLMProvider): @classmethod def _extract_usage(cls, response: Any) -> dict[str, int]: + """Extract token usage from an OpenAI-compatible response. + + Handles both dict-based (raw JSON) and object-based (SDK Pydantic) + responses. Provider-specific ``cached_tokens`` fields are normalised + under a single key; see the priority chain inside for details. + """ + # --- resolve usage object --- usage_obj = None response_map = cls._maybe_mapping(response) if response_map is not None: @@ -319,19 +326,53 @@ class OpenAICompatProvider(LLMProvider): usage_map = cls._maybe_mapping(usage_obj) if usage_map is not None: - return { + result = { "prompt_tokens": int(usage_map.get("prompt_tokens") or 0), "completion_tokens": int(usage_map.get("completion_tokens") or 0), "total_tokens": int(usage_map.get("total_tokens") or 0), } - - if usage_obj: - return { + elif usage_obj: + result = { "prompt_tokens": getattr(usage_obj, "prompt_tokens", 0) or 0, "completion_tokens": getattr(usage_obj, "completion_tokens", 0) or 0, "total_tokens": getattr(usage_obj, "total_tokens", 0) or 0, } - return {} + else: + return {} + + # --- cached_tokens (normalised across providers) --- + # Try nested paths first (dict), fall back to attribute (SDK object). + # Priority order ensures the most specific field wins. + for path in ( + ("prompt_tokens_details", "cached_tokens"), # OpenAI/Zhipu/MiniMax/Qwen/Mistral/xAI + ("cached_tokens",), # StepFun/Moonshot (top-level) + ("prompt_cache_hit_tokens",), # DeepSeek/SiliconFlow + ): + cached = cls._get_nested_int(usage_map, path) + if not cached and usage_obj: + cached = cls._get_nested_int(usage_obj, path) + if cached: + result["cached_tokens"] = cached + break + + return result + + @staticmethod + def _get_nested_int(obj: Any, path: tuple[str, ...]) -> int: + """Drill into *obj* by *path* segments and return an ``int`` value. + + Supports both dict-key access and attribute access so it works + uniformly with raw JSON dicts **and** SDK Pydantic models. + """ + current = obj + for segment in path: + if current is None: + return 0 + if isinstance(current, dict): + current = current.get(segment) + else: + current = getattr(current, segment, None) + return int(current or 0) if current is not None else 0 def _parse(self, response: Any) -> LLMResponse: if isinstance(response, str): diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index a7c2c257..406a4dd4 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -255,14 +255,18 @@ def build_status_content( ) last_in = last_usage.get("prompt_tokens", 0) last_out = last_usage.get("completion_tokens", 0) + cached = last_usage.get("cached_tokens", 0) ctx_total = max(context_window_tokens, 0) ctx_pct = int((context_tokens_estimate / ctx_total) * 100) if ctx_total > 0 else 0 ctx_used_str = f"{context_tokens_estimate // 1000}k" if context_tokens_estimate >= 1000 else str(context_tokens_estimate) ctx_total_str = f"{ctx_total // 1024}k" if ctx_total > 0 else "n/a" + token_line = f"\U0001f4ca Tokens: {last_in} in / {last_out} out" + if cached and last_in: + token_line += f" ({cached * 100 // last_in}% cached)" return "\n".join([ f"\U0001f408 nanobot v{version}", f"\U0001f9e0 Model: {model}", - f"\U0001f4ca Tokens: {last_in} in / {last_out} out", + token_line, f"\U0001f4da Context: {ctx_used_str}/{ctx_total_str} ({ctx_pct}%)", f"\U0001f4ac Session: {session_msg_count} messages", f"\u23f1 Uptime: {uptime}", diff --git a/tests/agent/test_runner.py b/tests/agent/test_runner.py index 86b0ba71..98f1d73a 100644 --- a/tests/agent/test_runner.py +++ b/tests/agent/test_runner.py @@ -333,3 +333,82 @@ async def test_subagent_max_iterations_announces_existing_fallback(tmp_path, mon args = mgr._announce_result.await_args.args assert args[3] == "Task completed but no final response was generated." assert args[5] == "ok" + + +@pytest.mark.asyncio +async def test_runner_accumulates_usage_and_preserves_cached_tokens(): + """Runner should accumulate prompt/completion tokens across iterations + and preserve cached_tokens from provider responses.""" + from nanobot.agent.runner import AgentRunSpec, AgentRunner + + provider = MagicMock() + call_count = {"n": 0} + + async def chat_with_retry(*, messages, **kwargs): + call_count["n"] += 1 + if call_count["n"] == 1: + return LLMResponse( + content="thinking", + tool_calls=[ToolCallRequest(id="call_1", name="read_file", arguments={"path": "x"})], + usage={"prompt_tokens": 100, "completion_tokens": 10, "cached_tokens": 80}, + ) + return LLMResponse( + content="done", + tool_calls=[], + usage={"prompt_tokens": 200, "completion_tokens": 20, "cached_tokens": 150}, + ) + + provider.chat_with_retry = chat_with_retry + tools = MagicMock() + tools.get_definitions.return_value = [] + tools.execute = AsyncMock(return_value="file content") + + runner = AgentRunner(provider) + result = await runner.run(AgentRunSpec( + initial_messages=[{"role": "user", "content": "do task"}], + tools=tools, + model="test-model", + max_iterations=3, + )) + + # Usage should be accumulated across iterations + assert result.usage["prompt_tokens"] == 300 # 100 + 200 + assert result.usage["completion_tokens"] == 30 # 10 + 20 + assert result.usage["cached_tokens"] == 230 # 80 + 150 + + +@pytest.mark.asyncio +async def test_runner_passes_cached_tokens_to_hook_context(): + """Hook context.usage should contain cached_tokens.""" + from nanobot.agent.hook import AgentHook, AgentHookContext + from nanobot.agent.runner import AgentRunSpec, AgentRunner + + provider = MagicMock() + captured_usage: list[dict] = [] + + class UsageHook(AgentHook): + async def after_iteration(self, context: AgentHookContext) -> None: + captured_usage.append(dict(context.usage)) + + async def chat_with_retry(**kwargs): + return LLMResponse( + content="done", + tool_calls=[], + usage={"prompt_tokens": 200, "completion_tokens": 20, "cached_tokens": 150}, + ) + + provider.chat_with_retry = chat_with_retry + tools = MagicMock() + tools.get_definitions.return_value = [] + + runner = AgentRunner(provider) + await runner.run(AgentRunSpec( + initial_messages=[], + tools=tools, + model="test-model", + max_iterations=1, + hook=UsageHook(), + )) + + assert len(captured_usage) == 1 + assert captured_usage[0]["cached_tokens"] == 150 diff --git a/tests/cli/test_restart_command.py b/tests/cli/test_restart_command.py index 3281afe2..6efcdad0 100644 --- a/tests/cli/test_restart_command.py +++ b/tests/cli/test_restart_command.py @@ -152,10 +152,12 @@ class TestRestartCommand: ]) await loop._run_agent_loop([]) - assert loop._last_usage == {"prompt_tokens": 9, "completion_tokens": 4} + assert loop._last_usage["prompt_tokens"] == 9 + assert loop._last_usage["completion_tokens"] == 4 await loop._run_agent_loop([]) - assert loop._last_usage == {"prompt_tokens": 0, "completion_tokens": 0} + assert loop._last_usage["prompt_tokens"] == 0 + assert loop._last_usage["completion_tokens"] == 0 @pytest.mark.asyncio async def test_status_falls_back_to_last_usage_when_context_estimate_missing(self): diff --git a/tests/providers/test_cached_tokens.py b/tests/providers/test_cached_tokens.py new file mode 100644 index 00000000..fce22cf6 --- /dev/null +++ b/tests/providers/test_cached_tokens.py @@ -0,0 +1,231 @@ +"""Tests for cached token extraction from OpenAI-compatible providers.""" + +from __future__ import annotations + +from nanobot.providers.openai_compat_provider import OpenAICompatProvider + + +class FakeUsage: + """Mimics an OpenAI SDK usage object (has attributes, not dict keys).""" + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + +class FakePromptDetails: + """Mimics prompt_tokens_details sub-object.""" + def __init__(self, cached_tokens=0): + self.cached_tokens = cached_tokens + + +class _FakeSpec: + supports_prompt_caching = False + model_id_prefix = None + strip_model_prefix = False + max_completion_tokens = False + reasoning_effort = None + + +def _provider(): + from unittest.mock import MagicMock + p = OpenAICompatProvider.__new__(OpenAICompatProvider) + p.client = MagicMock() + p.spec = _FakeSpec() + return p + + +# Minimal valid choice so _parse reaches _extract_usage. +_DICT_CHOICE = {"message": {"content": "Hello"}} + +class _FakeMessage: + content = "Hello" + tool_calls = None + + +class _FakeChoice: + message = _FakeMessage() + finish_reason = "stop" + + +# --- dict-based response (raw JSON / mapping) --- + +def test_extract_usage_openai_cached_tokens_dict(): + """prompt_tokens_details.cached_tokens from a dict response.""" + p = _provider() + response = { + "choices": [_DICT_CHOICE], + "usage": { + "prompt_tokens": 2000, + "completion_tokens": 300, + "total_tokens": 2300, + "prompt_tokens_details": {"cached_tokens": 1200}, + } + } + result = p._parse(response) + assert result.usage["cached_tokens"] == 1200 + assert result.usage["prompt_tokens"] == 2000 + + +def test_extract_usage_deepseek_cached_tokens_dict(): + """prompt_cache_hit_tokens from a DeepSeek dict response.""" + p = _provider() + response = { + "choices": [_DICT_CHOICE], + "usage": { + "prompt_tokens": 1500, + "completion_tokens": 200, + "total_tokens": 1700, + "prompt_cache_hit_tokens": 1200, + "prompt_cache_miss_tokens": 300, + } + } + result = p._parse(response) + assert result.usage["cached_tokens"] == 1200 + + +def test_extract_usage_no_cached_tokens_dict(): + """Response without any cache fields -> no cached_tokens key.""" + p = _provider() + response = { + "choices": [_DICT_CHOICE], + "usage": { + "prompt_tokens": 1000, + "completion_tokens": 200, + "total_tokens": 1200, + } + } + result = p._parse(response) + assert "cached_tokens" not in result.usage + + +def test_extract_usage_openai_cached_zero_dict(): + """cached_tokens=0 should NOT be included (same as existing fields).""" + p = _provider() + response = { + "choices": [_DICT_CHOICE], + "usage": { + "prompt_tokens": 2000, + "completion_tokens": 300, + "total_tokens": 2300, + "prompt_tokens_details": {"cached_tokens": 0}, + } + } + result = p._parse(response) + assert "cached_tokens" not in result.usage + + +# --- object-based response (OpenAI SDK Pydantic model) --- + +def test_extract_usage_openai_cached_tokens_obj(): + """prompt_tokens_details.cached_tokens from an SDK object response.""" + p = _provider() + usage_obj = FakeUsage( + prompt_tokens=2000, + completion_tokens=300, + total_tokens=2300, + prompt_tokens_details=FakePromptDetails(cached_tokens=1200), + ) + response = FakeUsage(choices=[_FakeChoice()], usage=usage_obj) + result = p._parse(response) + assert result.usage["cached_tokens"] == 1200 + + +def test_extract_usage_deepseek_cached_tokens_obj(): + """prompt_cache_hit_tokens from a DeepSeek SDK object response.""" + p = _provider() + usage_obj = FakeUsage( + prompt_tokens=1500, + completion_tokens=200, + total_tokens=1700, + prompt_cache_hit_tokens=1200, + ) + response = FakeUsage(choices=[_FakeChoice()], usage=usage_obj) + result = p._parse(response) + assert result.usage["cached_tokens"] == 1200 + + +def test_extract_usage_stepfun_top_level_cached_tokens_dict(): + """StepFun/Moonshot: usage.cached_tokens at top level (not nested).""" + p = _provider() + response = { + "choices": [_DICT_CHOICE], + "usage": { + "prompt_tokens": 591, + "completion_tokens": 120, + "total_tokens": 711, + "cached_tokens": 512, + } + } + result = p._parse(response) + assert result.usage["cached_tokens"] == 512 + + +def test_extract_usage_stepfun_top_level_cached_tokens_obj(): + """StepFun/Moonshot: usage.cached_tokens as SDK object attribute.""" + p = _provider() + usage_obj = FakeUsage( + prompt_tokens=591, + completion_tokens=120, + total_tokens=711, + cached_tokens=512, + ) + response = FakeUsage(choices=[_FakeChoice()], usage=usage_obj) + result = p._parse(response) + assert result.usage["cached_tokens"] == 512 + + +def test_extract_usage_priority_nested_over_top_level_dict(): + """When both nested and top-level cached_tokens exist, nested wins.""" + p = _provider() + response = { + "choices": [_DICT_CHOICE], + "usage": { + "prompt_tokens": 2000, + "completion_tokens": 300, + "total_tokens": 2300, + "prompt_tokens_details": {"cached_tokens": 100}, + "cached_tokens": 500, + } + } + result = p._parse(response) + assert result.usage["cached_tokens"] == 100 + + +def test_anthropic_maps_cache_fields_to_cached_tokens(): + """Anthropic's cache_read_input_tokens should map to cached_tokens.""" + from nanobot.providers.anthropic_provider import AnthropicProvider + + usage_obj = FakeUsage( + input_tokens=800, + output_tokens=200, + cache_creation_input_tokens=0, + cache_read_input_tokens=1200, + ) + content_block = FakeUsage(type="text", text="hello") + response = FakeUsage( + id="msg_1", + type="message", + stop_reason="end_turn", + content=[content_block], + usage=usage_obj, + ) + result = AnthropicProvider._parse_response(response) + assert result.usage["cached_tokens"] == 1200 + assert result.usage["prompt_tokens"] == 800 + + +def test_anthropic_no_cache_fields(): + """Anthropic response without cache fields should not have cached_tokens.""" + from nanobot.providers.anthropic_provider import AnthropicProvider + + usage_obj = FakeUsage(input_tokens=800, output_tokens=200) + content_block = FakeUsage(type="text", text="hello") + response = FakeUsage( + id="msg_1", + type="message", + stop_reason="end_turn", + content=[content_block], + usage=usage_obj, + ) + result = AnthropicProvider._parse_response(response) + assert "cached_tokens" not in result.usage diff --git a/tests/test_build_status.py b/tests/test_build_status.py new file mode 100644 index 00000000..d98301cf --- /dev/null +++ b/tests/test_build_status.py @@ -0,0 +1,59 @@ +"""Tests for build_status_content cache hit rate display.""" + +from nanobot.utils.helpers import build_status_content + + +def test_status_shows_cache_hit_rate(): + content = build_status_content( + version="0.1.0", + model="glm-4-plus", + start_time=1000000.0, + last_usage={"prompt_tokens": 2000, "completion_tokens": 300, "cached_tokens": 1200}, + context_window_tokens=128000, + session_msg_count=10, + context_tokens_estimate=5000, + ) + assert "60% cached" in content + assert "2000 in / 300 out" in content + + +def test_status_no_cache_info(): + """Without cached_tokens, display should not show cache percentage.""" + content = build_status_content( + version="0.1.0", + model="glm-4-plus", + start_time=1000000.0, + last_usage={"prompt_tokens": 2000, "completion_tokens": 300}, + context_window_tokens=128000, + session_msg_count=10, + context_tokens_estimate=5000, + ) + assert "cached" not in content.lower() + assert "2000 in / 300 out" in content + + +def test_status_zero_cached_tokens(): + """cached_tokens=0 should not show cache percentage.""" + content = build_status_content( + version="0.1.0", + model="glm-4-plus", + start_time=1000000.0, + last_usage={"prompt_tokens": 2000, "completion_tokens": 300, "cached_tokens": 0}, + context_window_tokens=128000, + session_msg_count=10, + context_tokens_estimate=5000, + ) + assert "cached" not in content.lower() + + +def test_status_100_percent_cached(): + content = build_status_content( + version="0.1.0", + model="glm-4-plus", + start_time=1000000.0, + last_usage={"prompt_tokens": 1000, "completion_tokens": 100, "cached_tokens": 1000}, + context_window_tokens=128000, + session_msg_count=5, + context_tokens_estimate=3000, + ) + assert "100% cached" in content From a3e4c77fff90242f4bd5c344789adc9e46c5ee2e Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Thu, 2 Apr 2026 04:48:11 +0000 Subject: [PATCH 56/91] fix(providers): normalize anthropic cached token usage --- nanobot/providers/anthropic_provider.py | 9 ++++++--- tests/providers/test_cached_tokens.py | 6 ++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/nanobot/providers/anthropic_provider.py b/nanobot/providers/anthropic_provider.py index fabcd565..8e102d30 100644 --- a/nanobot/providers/anthropic_provider.py +++ b/nanobot/providers/anthropic_provider.py @@ -370,17 +370,20 @@ class AnthropicProvider(LLMProvider): usage: dict[str, int] = {} if response.usage: + input_tokens = response.usage.input_tokens + cache_creation = getattr(response.usage, "cache_creation_input_tokens", 0) or 0 + cache_read = getattr(response.usage, "cache_read_input_tokens", 0) or 0 + total_prompt_tokens = input_tokens + cache_creation + cache_read usage = { - "prompt_tokens": response.usage.input_tokens, + "prompt_tokens": total_prompt_tokens, "completion_tokens": response.usage.output_tokens, - "total_tokens": response.usage.input_tokens + response.usage.output_tokens, + "total_tokens": total_prompt_tokens + response.usage.output_tokens, } for attr in ("cache_creation_input_tokens", "cache_read_input_tokens"): val = getattr(response.usage, attr, 0) if val: usage[attr] = val # Normalize to cached_tokens for downstream consistency. - cache_read = usage.get("cache_read_input_tokens", 0) if cache_read: usage["cached_tokens"] = cache_read diff --git a/tests/providers/test_cached_tokens.py b/tests/providers/test_cached_tokens.py index fce22cf6..1b01408a 100644 --- a/tests/providers/test_cached_tokens.py +++ b/tests/providers/test_cached_tokens.py @@ -198,7 +198,7 @@ def test_anthropic_maps_cache_fields_to_cached_tokens(): usage_obj = FakeUsage( input_tokens=800, output_tokens=200, - cache_creation_input_tokens=0, + cache_creation_input_tokens=300, cache_read_input_tokens=1200, ) content_block = FakeUsage(type="text", text="hello") @@ -211,7 +211,9 @@ def test_anthropic_maps_cache_fields_to_cached_tokens(): ) result = AnthropicProvider._parse_response(response) assert result.usage["cached_tokens"] == 1200 - assert result.usage["prompt_tokens"] == 800 + assert result.usage["prompt_tokens"] == 2300 + assert result.usage["total_tokens"] == 2500 + assert result.usage["cache_creation_input_tokens"] == 300 def test_anthropic_no_cache_fields(): From 73e80b199a97b7576fa1c7c5a93f526076d7d27b Mon Sep 17 00:00:00 2001 From: lucario <912156837@qq.com> Date: Wed, 1 Apr 2026 23:17:13 +0800 Subject: [PATCH 57/91] feat(cron): add deliver parameter to support silent jobs, default true for backward compatibility --- nanobot/agent/tools/cron.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index 00f726c0..89b403b7 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -97,9 +97,14 @@ class CronTool(Tool): f"(e.g. '2026-02-12T10:30:00'). Naive values default to {self._default_timezone}." ), }, - "job_id": {"type": "string", "description": "Job ID (for remove)"}, - }, - "required": ["action"], + "job_id": {"type": "string", "description": "Job ID (for remove)"}, + "deliver": { + "type": "boolean", + "description": "Whether to deliver the execution result to the user channel (default true)", + "default": true + }, + }, + "required": ["action"], } async def execute( @@ -111,12 +116,13 @@ class CronTool(Tool): tz: str | None = None, at: str | None = None, job_id: str | None = None, + deliver: bool = True, **kwargs: Any, ) -> str: if action == "add": if self._in_cron_context.get(): return "Error: cannot schedule new jobs from within a cron job execution" - return self._add_job(message, every_seconds, cron_expr, tz, at) + return self._add_job(message, every_seconds, cron_expr, tz, at, deliver) elif action == "list": return self._list_jobs() elif action == "remove": @@ -130,6 +136,7 @@ class CronTool(Tool): cron_expr: str | None, tz: str | None, at: str | None, + deliver: bool = True, ) -> str: if not message: return "Error: message is required for add" @@ -171,7 +178,7 @@ class CronTool(Tool): name=message[:30], schedule=schedule, message=message, - deliver=True, + deliver=deliver, channel=self._channel, to=self._chat_id, delete_after_run=delete_after, From 2e3cb5b20e1eba863ea05b8e35eb377e9030378b Mon Sep 17 00:00:00 2001 From: archlinux Date: Wed, 1 Apr 2026 23:25:11 +0800 Subject: [PATCH 58/91] fix default value True --- nanobot/agent/tools/cron.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index 89b403b7..a78ab89b 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -101,7 +101,7 @@ class CronTool(Tool): "deliver": { "type": "boolean", "description": "Whether to deliver the execution result to the user channel (default true)", - "default": true + "default": True }, }, "required": ["action"], From 5f2157baeb1da922fbfa154f2bd7b6f72213c2b1 Mon Sep 17 00:00:00 2001 From: lucario <912156837@qq.com> Date: Thu, 2 Apr 2026 00:05:53 +0800 Subject: [PATCH 59/91] fix(cron): move deliver param before job_id in parameters schema --- nanobot/agent/tools/cron.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index a78ab89b..850ecdc4 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -97,12 +97,12 @@ class CronTool(Tool): f"(e.g. '2026-02-12T10:30:00'). Naive values default to {self._default_timezone}." ), }, - "job_id": {"type": "string", "description": "Job ID (for remove)"}, "deliver": { "type": "boolean", "description": "Whether to deliver the execution result to the user channel (default true)", "default": True }, + "job_id": {"type": "string", "description": "Job ID (for remove)"}, }, "required": ["action"], } From 35b51c0694c87d13d5a2e40603390c5584673946 Mon Sep 17 00:00:00 2001 From: lucario <912156837@qq.com> Date: Thu, 2 Apr 2026 00:15:39 +0800 Subject: [PATCH 60/91] fix(cron): fix extra indent for deliver param --- nanobot/agent/tools/cron.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index 850ecdc4..5205d0d6 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -97,12 +97,12 @@ class CronTool(Tool): f"(e.g. '2026-02-12T10:30:00'). Naive values default to {self._default_timezone}." ), }, - "deliver": { - "type": "boolean", - "description": "Whether to deliver the execution result to the user channel (default true)", - "default": True - }, - "job_id": {"type": "string", "description": "Job ID (for remove)"}, + "deliver": { + "type": "boolean", + "description": "Whether to deliver the execution result to the user channel (default true)", + "default": True + }, + "job_id": {"type": "string", "description": "Job ID (for remove)"}, }, "required": ["action"], } From 15faa3b1151e4ec5c350f346ece9dc3265bf342a Mon Sep 17 00:00:00 2001 From: lucario <912156837@qq.com> Date: Thu, 2 Apr 2026 00:17:26 +0800 Subject: [PATCH 61/91] fix(cron): fix extra indent for properties closing brace and required field --- nanobot/agent/tools/cron.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index 5205d0d6..f2aba0b9 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -103,8 +103,8 @@ class CronTool(Tool): "default": True }, "job_id": {"type": "string", "description": "Job ID (for remove)"}, - }, - "required": ["action"], + }, + "required": ["action"], } async def execute( From 9ba413c82e2157c2f2f4123efb79c42fa5783f60 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Thu, 2 Apr 2026 04:57:29 +0000 Subject: [PATCH 62/91] test(cron): cover deliver flag on scheduled jobs --- tests/cron/test_cron_tool_list.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/cron/test_cron_tool_list.py b/tests/cron/test_cron_tool_list.py index 22a502fa..42ad7d41 100644 --- a/tests/cron/test_cron_tool_list.py +++ b/tests/cron/test_cron_tool_list.py @@ -285,6 +285,28 @@ def test_add_at_job_uses_default_timezone_for_naive_datetime(tmp_path) -> None: assert job.schedule.at_ms == expected +def test_add_job_delivers_by_default(tmp_path) -> None: + tool = _make_tool(tmp_path) + tool.set_context("telegram", "chat-1") + + result = tool._add_job("Morning standup", 60, None, None, None) + + assert result.startswith("Created job") + job = tool._cron.list_jobs()[0] + assert job.payload.deliver is True + + +def test_add_job_can_disable_delivery(tmp_path) -> None: + tool = _make_tool(tmp_path) + tool.set_context("telegram", "chat-1") + + result = tool._add_job("Background refresh", 60, None, None, None, deliver=False) + + assert result.startswith("Created job") + job = tool._cron.list_jobs()[0] + assert job.payload.deliver is False + + def test_list_excludes_disabled_jobs(tmp_path) -> None: tool = _make_tool(tmp_path) job = tool._cron.add_job( From 0417c3f03b6b1a5fdd61e6a48c8896c1222c66b6 Mon Sep 17 00:00:00 2001 From: Kunal Karmakar Date: Tue, 31 Mar 2026 02:05:59 +0000 Subject: [PATCH 63/91] Use OpenAI responses API --- nanobot/providers/azure_openai_provider.py | 316 +++----- nanobot/providers/openai_codex_provider.py | 192 +---- .../openai_responses_common/__init__.py | 27 + .../openai_responses_common/converters.py | 110 +++ .../openai_responses_common/parsing.py | 173 +++++ tests/providers/test_azure_openai_provider.py | 679 +++++++++--------- 6 files changed, 769 insertions(+), 728 deletions(-) create mode 100644 nanobot/providers/openai_responses_common/__init__.py create mode 100644 nanobot/providers/openai_responses_common/converters.py create mode 100644 nanobot/providers/openai_responses_common/parsing.py diff --git a/nanobot/providers/azure_openai_provider.py b/nanobot/providers/azure_openai_provider.py index d71dae91..ab4d187a 100644 --- a/nanobot/providers/azure_openai_provider.py +++ b/nanobot/providers/azure_openai_provider.py @@ -1,31 +1,37 @@ -"""Azure OpenAI provider implementation with API version 2024-10-21.""" +"""Azure OpenAI provider using the OpenAI SDK Responses API. + +Uses ``AsyncOpenAI`` pointed at ``https://{endpoint}/openai/v1/`` which +routes to the Responses API (``/responses``). Reuses shared conversion +helpers from :mod:`nanobot.providers.openai_responses_common`. +""" from __future__ import annotations -import json import uuid from collections.abc import Awaitable, Callable from typing import Any -from urllib.parse import urljoin import httpx -import json_repair +from openai import AsyncOpenAI -from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest - -_AZURE_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name"}) +from nanobot.providers.base import LLMProvider, LLMResponse +from nanobot.providers.openai_responses_common import ( + consume_sse, + convert_messages, + convert_tools, + parse_response_output, +) class AzureOpenAIProvider(LLMProvider): - """ - Azure OpenAI provider with API version 2024-10-21 compliance. - + """Azure OpenAI provider backed by the Responses API. + Features: - - Hardcoded API version 2024-10-21 - - Uses model field as Azure deployment name in URL path - - Uses api-key header instead of Authorization Bearer - - Uses max_completion_tokens instead of max_tokens - - Direct HTTP calls, bypasses LiteLLM + - Uses the OpenAI Python SDK (``AsyncOpenAI``) with + ``base_url = {endpoint}/openai/v1/`` + - Calls ``client.responses.create()`` (Responses API) + - Reuses shared message/tool/SSE conversion from + ``openai_responses_common`` """ def __init__( @@ -36,40 +42,28 @@ class AzureOpenAIProvider(LLMProvider): ): super().__init__(api_key, api_base) self.default_model = default_model - self.api_version = "2024-10-21" - - # Validate required parameters + if not api_key: raise ValueError("Azure OpenAI api_key is required") if not api_base: raise ValueError("Azure OpenAI api_base is required") - - # Ensure api_base ends with / - if not api_base.endswith('/'): - api_base += '/' + + # Normalise: ensure trailing slash + if not api_base.endswith("/"): + api_base += "/" self.api_base = api_base - def _build_chat_url(self, deployment_name: str) -> str: - """Build the Azure OpenAI chat completions URL.""" - # Azure OpenAI URL format: - # https://{resource}.openai.azure.com/openai/deployments/{deployment}/chat/completions?api-version={version} - base_url = self.api_base - if not base_url.endswith('/'): - base_url += '/' - - url = urljoin( - base_url, - f"openai/deployments/{deployment_name}/chat/completions" + # SDK client targeting the Azure Responses API endpoint + base_url = f"{api_base.rstrip('/')}/openai/v1/" + self._client = AsyncOpenAI( + api_key=api_key, + base_url=base_url, + default_headers={"x-session-affinity": uuid.uuid4().hex}, ) - return f"{url}?api-version={self.api_version}" - def _build_headers(self) -> dict[str, str]: - """Build headers for Azure OpenAI API with api-key header.""" - return { - "Content-Type": "application/json", - "api-key": self.api_key, # Azure OpenAI uses api-key header, not Authorization - "x-session-affinity": uuid.uuid4().hex, # For cache locality - } + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ @staticmethod def _supports_temperature( @@ -82,36 +76,50 @@ class AzureOpenAIProvider(LLMProvider): name = deployment_name.lower() return not any(token in name for token in ("gpt-5", "o1", "o3", "o4")) - def _prepare_request_payload( + def _build_body( self, - deployment_name: str, messages: list[dict[str, Any]], - tools: list[dict[str, Any]] | None = None, - max_tokens: int = 4096, - temperature: float = 0.7, - reasoning_effort: str | None = None, - tool_choice: str | dict[str, Any] | None = None, + tools: list[dict[str, Any]] | None, + model: str | None, + max_tokens: int, + temperature: float, + reasoning_effort: str | None, + tool_choice: str | dict[str, Any] | None, ) -> dict[str, Any]: - """Prepare the request payload with Azure OpenAI 2024-10-21 compliance.""" - payload: dict[str, Any] = { - "messages": self._sanitize_request_messages( - self._sanitize_empty_content(messages), - _AZURE_MSG_KEYS, - ), - "max_completion_tokens": max(1, max_tokens), # Azure API 2024-10-21 uses max_completion_tokens + """Build the Responses API request body from Chat-Completions-style args.""" + deployment = model or self.default_model + instructions, input_items = convert_messages(messages) + + body: dict[str, Any] = { + "model": deployment, + "instructions": instructions or None, + "input": input_items, + "store": False, + "stream": False, } - if self._supports_temperature(deployment_name, reasoning_effort): - payload["temperature"] = temperature + if self._supports_temperature(deployment, reasoning_effort): + body["temperature"] = temperature if reasoning_effort: - payload["reasoning_effort"] = reasoning_effort + body["reasoning"] = {"effort": reasoning_effort} + body["include"] = ["reasoning.encrypted_content"] if tools: - payload["tools"] = tools - payload["tool_choice"] = tool_choice or "auto" + body["tools"] = convert_tools(tools) + body["tool_choice"] = tool_choice or "auto" - return payload + return body + + @staticmethod + def _handle_error(e: Exception) -> LLMResponse: + body = getattr(e, "body", None) or getattr(getattr(e, "response", None), "text", None) + msg = f"Error: {str(body).strip()[:500]}" if body else f"Error calling Azure OpenAI: {e}" + return LLMResponse(content=msg, finish_reason="error") + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ async def chat( self, @@ -123,92 +131,15 @@ class AzureOpenAIProvider(LLMProvider): reasoning_effort: str | None = None, tool_choice: str | dict[str, Any] | None = None, ) -> LLMResponse: - """ - Send a chat completion request to Azure OpenAI. - - Args: - messages: List of message dicts with 'role' and 'content'. - tools: Optional list of tool definitions in OpenAI format. - model: Model identifier (used as deployment name). - max_tokens: Maximum tokens in response (mapped to max_completion_tokens). - temperature: Sampling temperature. - reasoning_effort: Optional reasoning effort parameter. - - Returns: - LLMResponse with content and/or tool calls. - """ - deployment_name = model or self.default_model - url = self._build_chat_url(deployment_name) - headers = self._build_headers() - payload = self._prepare_request_payload( - deployment_name, messages, tools, max_tokens, temperature, reasoning_effort, - tool_choice=tool_choice, + body = self._build_body( + messages, tools, model, max_tokens, temperature, + reasoning_effort, tool_choice, ) - try: - async with httpx.AsyncClient(timeout=60.0, verify=True) as client: - response = await client.post(url, headers=headers, json=payload) - if response.status_code != 200: - return LLMResponse( - content=f"Azure OpenAI API Error {response.status_code}: {response.text}", - finish_reason="error", - ) - - response_data = response.json() - return self._parse_response(response_data) - + response = await self._client.responses.create(**body) + return parse_response_output(response) except Exception as e: - return LLMResponse( - content=f"Error calling Azure OpenAI: {repr(e)}", - finish_reason="error", - ) - - def _parse_response(self, response: dict[str, Any]) -> LLMResponse: - """Parse Azure OpenAI response into our standard format.""" - try: - choice = response["choices"][0] - message = choice["message"] - - tool_calls = [] - if message.get("tool_calls"): - for tc in message["tool_calls"]: - # Parse arguments from JSON string if needed - args = tc["function"]["arguments"] - if isinstance(args, str): - args = json_repair.loads(args) - - tool_calls.append( - ToolCallRequest( - id=tc["id"], - name=tc["function"]["name"], - arguments=args, - ) - ) - - usage = {} - if response.get("usage"): - usage_data = response["usage"] - usage = { - "prompt_tokens": usage_data.get("prompt_tokens", 0), - "completion_tokens": usage_data.get("completion_tokens", 0), - "total_tokens": usage_data.get("total_tokens", 0), - } - - reasoning_content = message.get("reasoning_content") or None - - return LLMResponse( - content=message.get("content"), - tool_calls=tool_calls, - finish_reason=choice.get("finish_reason", "stop"), - usage=usage, - reasoning_content=reasoning_content, - ) - - except (KeyError, IndexError) as e: - return LLMResponse( - content=f"Error parsing Azure OpenAI response: {str(e)}", - finish_reason="error", - ) + return self._handle_error(e) async def chat_stream( self, @@ -221,89 +152,40 @@ class AzureOpenAIProvider(LLMProvider): tool_choice: str | dict[str, Any] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None, ) -> LLMResponse: - """Stream a chat completion via Azure OpenAI SSE.""" - deployment_name = model or self.default_model - url = self._build_chat_url(deployment_name) - headers = self._build_headers() - payload = self._prepare_request_payload( - deployment_name, messages, tools, max_tokens, temperature, - reasoning_effort, tool_choice=tool_choice, + body = self._build_body( + messages, tools, model, max_tokens, temperature, + reasoning_effort, tool_choice, ) - payload["stream"] = True + body["stream"] = True try: - async with httpx.AsyncClient(timeout=60.0, verify=True) as client: - async with client.stream("POST", url, headers=headers, json=payload) as response: + # Use raw httpx stream via the SDK's base URL so we can reuse + # the shared Responses-API SSE parser (same as Codex provider). + base_url = str(self._client.base_url).rstrip("/") + url = f"{base_url}/responses" + headers = { + "Authorization": f"Bearer {self._client.api_key}", + "Content-Type": "application/json", + **(self._client._custom_headers or {}), + } + async with httpx.AsyncClient(timeout=60.0, verify=True) as http: + async with http.stream("POST", url, headers=headers, json=body) as response: if response.status_code != 200: text = await response.aread() return LLMResponse( content=f"Azure OpenAI API Error {response.status_code}: {text.decode('utf-8', 'ignore')}", finish_reason="error", ) - return await self._consume_stream(response, on_content_delta) + content, tool_calls, finish_reason = await consume_sse( + response, on_content_delta, + ) + return LLMResponse( + content=content or None, + tool_calls=tool_calls, + finish_reason=finish_reason, + ) except Exception as e: - return LLMResponse(content=f"Error calling Azure OpenAI: {repr(e)}", finish_reason="error") - - async def _consume_stream( - self, - response: httpx.Response, - on_content_delta: Callable[[str], Awaitable[None]] | None, - ) -> LLMResponse: - """Parse Azure OpenAI SSE stream into an LLMResponse.""" - content_parts: list[str] = [] - tool_call_buffers: dict[int, dict[str, str]] = {} - finish_reason = "stop" - - async for line in response.aiter_lines(): - if not line.startswith("data: "): - continue - data = line[6:].strip() - if data == "[DONE]": - break - try: - chunk = json.loads(data) - except Exception: - continue - - choices = chunk.get("choices") or [] - if not choices: - continue - choice = choices[0] - if choice.get("finish_reason"): - finish_reason = choice["finish_reason"] - delta = choice.get("delta") or {} - - text = delta.get("content") - if text: - content_parts.append(text) - if on_content_delta: - await on_content_delta(text) - - for tc in delta.get("tool_calls") or []: - idx = tc.get("index", 0) - buf = tool_call_buffers.setdefault(idx, {"id": "", "name": "", "arguments": ""}) - if tc.get("id"): - buf["id"] = tc["id"] - fn = tc.get("function") or {} - if fn.get("name"): - buf["name"] = fn["name"] - if fn.get("arguments"): - buf["arguments"] += fn["arguments"] - - tool_calls = [ - ToolCallRequest( - id=buf["id"], name=buf["name"], - arguments=json_repair.loads(buf["arguments"]) if buf["arguments"] else {}, - ) - for buf in tool_call_buffers.values() - ] - - return LLMResponse( - content="".join(content_parts) or None, - tool_calls=tool_calls, - finish_reason=finish_reason, - ) + return self._handle_error(e) def get_default_model(self) -> str: - """Get the default model (also used as default deployment name).""" return self.default_model \ No newline at end of file diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py index 1c6bc707..68145173 100644 --- a/nanobot/providers/openai_codex_provider.py +++ b/nanobot/providers/openai_codex_provider.py @@ -6,13 +6,18 @@ import asyncio import hashlib import json from collections.abc import Awaitable, Callable -from typing import Any, AsyncGenerator +from typing import Any import httpx from loguru import logger from oauth_cli_kit import get_token as get_codex_token from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest +from nanobot.providers.openai_responses_common import ( + consume_sse, + convert_messages, + convert_tools, +) DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses" DEFAULT_ORIGINATOR = "nanobot" @@ -36,7 +41,7 @@ class OpenAICodexProvider(LLMProvider): ) -> LLMResponse: """Shared request logic for both chat() and chat_stream().""" model = model or self.default_model - system_prompt, input_items = _convert_messages(messages) + system_prompt, input_items = convert_messages(messages) token = await asyncio.to_thread(get_codex_token) headers = _build_headers(token.account_id, token.access) @@ -56,7 +61,7 @@ class OpenAICodexProvider(LLMProvider): if reasoning_effort: body["reasoning"] = {"effort": reasoning_effort} if tools: - body["tools"] = _convert_tools(tools) + body["tools"] = convert_tools(tools) try: try: @@ -127,96 +132,7 @@ async def _request_codex( if response.status_code != 200: text = await response.aread() raise RuntimeError(_friendly_error(response.status_code, text.decode("utf-8", "ignore"))) - return await _consume_sse(response, on_content_delta) - - -def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Convert OpenAI function-calling schema to Codex flat format.""" - converted: list[dict[str, Any]] = [] - for tool in tools: - fn = (tool.get("function") or {}) if tool.get("type") == "function" else tool - name = fn.get("name") - if not name: - continue - params = fn.get("parameters") or {} - converted.append({ - "type": "function", - "name": name, - "description": fn.get("description") or "", - "parameters": params if isinstance(params, dict) else {}, - }) - return converted - - -def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]: - system_prompt = "" - input_items: list[dict[str, Any]] = [] - - for idx, msg in enumerate(messages): - role = msg.get("role") - content = msg.get("content") - - if role == "system": - system_prompt = content if isinstance(content, str) else "" - continue - - if role == "user": - input_items.append(_convert_user_message(content)) - continue - - if role == "assistant": - if isinstance(content, str) and content: - input_items.append({ - "type": "message", "role": "assistant", - "content": [{"type": "output_text", "text": content}], - "status": "completed", "id": f"msg_{idx}", - }) - for tool_call in msg.get("tool_calls", []) or []: - fn = tool_call.get("function") or {} - call_id, item_id = _split_tool_call_id(tool_call.get("id")) - input_items.append({ - "type": "function_call", - "id": item_id or f"fc_{idx}", - "call_id": call_id or f"call_{idx}", - "name": fn.get("name"), - "arguments": fn.get("arguments") or "{}", - }) - continue - - 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, ensure_ascii=False) - input_items.append({"type": "function_call_output", "call_id": call_id, "output": output_text}) - - return system_prompt, input_items - - -def _convert_user_message(content: Any) -> dict[str, Any]: - if isinstance(content, str): - return {"role": "user", "content": [{"type": "input_text", "text": content}]} - if isinstance(content, list): - converted: list[dict[str, Any]] = [] - for item in content: - if not isinstance(item, dict): - continue - if item.get("type") == "text": - converted.append({"type": "input_text", "text": item.get("text", "")}) - elif item.get("type") == "image_url": - url = (item.get("image_url") or {}).get("url") - if url: - converted.append({"type": "input_image", "image_url": url, "detail": "auto"}) - if converted: - return {"role": "user", "content": converted} - return {"role": "user", "content": [{"type": "input_text", "text": ""}]} - - -def _split_tool_call_id(tool_call_id: Any) -> tuple[str, str | None]: - if isinstance(tool_call_id, str) and tool_call_id: - if "|" in tool_call_id: - call_id, item_id = tool_call_id.split("|", 1) - return call_id, item_id or None - return tool_call_id, None - return "call_0", None + return await consume_sse(response, on_content_delta) def _prompt_cache_key(messages: list[dict[str, Any]]) -> str: @@ -224,96 +140,6 @@ def _prompt_cache_key(messages: list[dict[str, Any]]) -> str: return hashlib.sha256(raw.encode("utf-8")).hexdigest() -async def _iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any], None]: - buffer: list[str] = [] - async for line in response.aiter_lines(): - if line == "": - if buffer: - data_lines = [l[5:].strip() for l in buffer if l.startswith("data:")] - buffer = [] - if not data_lines: - continue - data = "\n".join(data_lines).strip() - if not data or data == "[DONE]": - continue - try: - yield json.loads(data) - except Exception: - continue - continue - buffer.append(line) - - -async def _consume_sse( - response: httpx.Response, - on_content_delta: Callable[[str], Awaitable[None]] | None = None, -) -> tuple[str, list[ToolCallRequest], str]: - content = "" - tool_calls: list[ToolCallRequest] = [] - tool_call_buffers: dict[str, dict[str, Any]] = {} - finish_reason = "stop" - - async for event in _iter_sse(response): - event_type = event.get("type") - if event_type == "response.output_item.added": - item = event.get("item") or {} - if item.get("type") == "function_call": - call_id = item.get("call_id") - if not call_id: - continue - tool_call_buffers[call_id] = { - "id": item.get("id") or "fc_0", - "name": item.get("name"), - "arguments": item.get("arguments") or "", - } - elif event_type == "response.output_text.delta": - delta_text = event.get("delta") or "" - content += delta_text - if on_content_delta and delta_text: - await on_content_delta(delta_text) - elif event_type == "response.function_call_arguments.delta": - call_id = event.get("call_id") - if call_id and call_id in tool_call_buffers: - tool_call_buffers[call_id]["arguments"] += event.get("delta") or "" - elif event_type == "response.function_call_arguments.done": - call_id = event.get("call_id") - if call_id and call_id in tool_call_buffers: - tool_call_buffers[call_id]["arguments"] = event.get("arguments") or "" - elif event_type == "response.output_item.done": - item = event.get("item") or {} - if item.get("type") == "function_call": - call_id = item.get("call_id") - if not call_id: - continue - buf = tool_call_buffers.get(call_id) or {} - args_raw = buf.get("arguments") or item.get("arguments") or "{}" - try: - args = json.loads(args_raw) - except Exception: - args = {"raw": args_raw} - tool_calls.append( - ToolCallRequest( - id=f"{call_id}|{buf.get('id') or item.get('id') or 'fc_0'}", - name=buf.get("name") or item.get("name"), - arguments=args, - ) - ) - elif event_type == "response.completed": - status = (event.get("response") or {}).get("status") - finish_reason = _map_finish_reason(status) - elif event_type in {"error", "response.failed"}: - raise RuntimeError("Codex response failed") - - return content, tool_calls, finish_reason - - -_FINISH_REASON_MAP = {"completed": "stop", "incomplete": "length", "failed": "error", "cancelled": "error"} - - -def _map_finish_reason(status: str | None) -> str: - return _FINISH_REASON_MAP.get(status or "completed", "stop") - - def _friendly_error(status_code: int, raw: str) -> str: if status_code == 429: return "ChatGPT usage quota exceeded or rate limit triggered. Please try again later." diff --git a/nanobot/providers/openai_responses_common/__init__.py b/nanobot/providers/openai_responses_common/__init__.py new file mode 100644 index 00000000..cfc327bd --- /dev/null +++ b/nanobot/providers/openai_responses_common/__init__.py @@ -0,0 +1,27 @@ +"""Shared helpers for OpenAI Responses API providers (Codex, Azure OpenAI).""" + +from nanobot.providers.openai_responses_common.converters import ( + convert_messages, + convert_tools, + convert_user_message, + split_tool_call_id, +) +from nanobot.providers.openai_responses_common.parsing import ( + FINISH_REASON_MAP, + consume_sse, + iter_sse, + map_finish_reason, + parse_response_output, +) + +__all__ = [ + "convert_messages", + "convert_tools", + "convert_user_message", + "split_tool_call_id", + "iter_sse", + "consume_sse", + "map_finish_reason", + "parse_response_output", + "FINISH_REASON_MAP", +] diff --git a/nanobot/providers/openai_responses_common/converters.py b/nanobot/providers/openai_responses_common/converters.py new file mode 100644 index 00000000..37596692 --- /dev/null +++ b/nanobot/providers/openai_responses_common/converters.py @@ -0,0 +1,110 @@ +"""Convert Chat Completions messages/tools to Responses API format.""" + +from __future__ import annotations + +import json +from typing import Any + + +def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]: + """Convert Chat Completions messages to Responses API input items. + + Returns ``(system_prompt, input_items)`` where *system_prompt* is extracted + from any ``system`` role message and *input_items* is the Responses API + ``input`` array. + """ + system_prompt = "" + input_items: list[dict[str, Any]] = [] + + for idx, msg in enumerate(messages): + role = msg.get("role") + content = msg.get("content") + + if role == "system": + system_prompt = content if isinstance(content, str) else "" + continue + + if role == "user": + input_items.append(convert_user_message(content)) + continue + + if role == "assistant": + if isinstance(content, str) and content: + input_items.append({ + "type": "message", "role": "assistant", + "content": [{"type": "output_text", "text": content}], + "status": "completed", "id": f"msg_{idx}", + }) + for tool_call in msg.get("tool_calls", []) or []: + fn = tool_call.get("function") or {} + call_id, item_id = split_tool_call_id(tool_call.get("id")) + input_items.append({ + "type": "function_call", + "id": item_id or f"fc_{idx}", + "call_id": call_id or f"call_{idx}", + "name": fn.get("name"), + "arguments": fn.get("arguments") or "{}", + }) + continue + + 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, ensure_ascii=False) + input_items.append({"type": "function_call_output", "call_id": call_id, "output": output_text}) + + return system_prompt, input_items + + +def convert_user_message(content: Any) -> dict[str, Any]: + """Convert a user message's content to Responses API format. + + Handles plain strings, ``text`` blocks → ``input_text``, and + ``image_url`` blocks → ``input_image``. + """ + if isinstance(content, str): + return {"role": "user", "content": [{"type": "input_text", "text": content}]} + if isinstance(content, list): + converted: list[dict[str, Any]] = [] + for item in content: + if not isinstance(item, dict): + continue + if item.get("type") == "text": + converted.append({"type": "input_text", "text": item.get("text", "")}) + elif item.get("type") == "image_url": + url = (item.get("image_url") or {}).get("url") + if url: + converted.append({"type": "input_image", "image_url": url, "detail": "auto"}) + if converted: + return {"role": "user", "content": converted} + return {"role": "user", "content": [{"type": "input_text", "text": ""}]} + + +def convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Convert OpenAI function-calling tool schema to Responses API flat format.""" + converted: list[dict[str, Any]] = [] + for tool in tools: + fn = (tool.get("function") or {}) if tool.get("type") == "function" else tool + name = fn.get("name") + if not name: + continue + params = fn.get("parameters") or {} + converted.append({ + "type": "function", + "name": name, + "description": fn.get("description") or "", + "parameters": params if isinstance(params, dict) else {}, + }) + return converted + + +def split_tool_call_id(tool_call_id: Any) -> tuple[str, str | None]: + """Split a compound ``call_id|item_id`` string. + + Returns ``(call_id, item_id)`` where *item_id* may be ``None``. + """ + if isinstance(tool_call_id, str) and tool_call_id: + if "|" in tool_call_id: + call_id, item_id = tool_call_id.split("|", 1) + return call_id, item_id or None + return tool_call_id, None + return "call_0", None diff --git a/nanobot/providers/openai_responses_common/parsing.py b/nanobot/providers/openai_responses_common/parsing.py new file mode 100644 index 00000000..e0d5f446 --- /dev/null +++ b/nanobot/providers/openai_responses_common/parsing.py @@ -0,0 +1,173 @@ +"""Parse Responses API SSE streams and SDK response objects.""" + +from __future__ import annotations + +import json +from collections.abc import Awaitable, Callable +from typing import Any, AsyncGenerator + +import httpx + +from nanobot.providers.base import LLMResponse, ToolCallRequest + +FINISH_REASON_MAP = { + "completed": "stop", + "incomplete": "length", + "failed": "error", + "cancelled": "error", +} + + +def map_finish_reason(status: str | None) -> str: + """Map a Responses API status string to a Chat-Completions-style finish_reason.""" + return FINISH_REASON_MAP.get(status or "completed", "stop") + + +async def iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any], None]: + """Yield parsed JSON events from a Responses API SSE stream.""" + buffer: list[str] = [] + async for line in response.aiter_lines(): + if line == "": + if buffer: + data_lines = [l[5:].strip() for l in buffer if l.startswith("data:")] + buffer = [] + if not data_lines: + continue + data = "\n".join(data_lines).strip() + if not data or data == "[DONE]": + continue + try: + yield json.loads(data) + except Exception: + continue + continue + buffer.append(line) + + +async def consume_sse( + response: httpx.Response, + on_content_delta: Callable[[str], Awaitable[None]] | None = None, +) -> tuple[str, list[ToolCallRequest], str]: + """Consume a Responses API SSE stream into ``(content, tool_calls, finish_reason)``.""" + content = "" + tool_calls: list[ToolCallRequest] = [] + tool_call_buffers: dict[str, dict[str, Any]] = {} + finish_reason = "stop" + + async for event in iter_sse(response): + event_type = event.get("type") + if event_type == "response.output_item.added": + item = event.get("item") or {} + if item.get("type") == "function_call": + call_id = item.get("call_id") + if not call_id: + continue + tool_call_buffers[call_id] = { + "id": item.get("id") or "fc_0", + "name": item.get("name"), + "arguments": item.get("arguments") or "", + } + elif event_type == "response.output_text.delta": + delta_text = event.get("delta") or "" + content += delta_text + if on_content_delta and delta_text: + await on_content_delta(delta_text) + elif event_type == "response.function_call_arguments.delta": + call_id = event.get("call_id") + if call_id and call_id in tool_call_buffers: + tool_call_buffers[call_id]["arguments"] += event.get("delta") or "" + elif event_type == "response.function_call_arguments.done": + call_id = event.get("call_id") + if call_id and call_id in tool_call_buffers: + tool_call_buffers[call_id]["arguments"] = event.get("arguments") or "" + elif event_type == "response.output_item.done": + item = event.get("item") or {} + if item.get("type") == "function_call": + call_id = item.get("call_id") + if not call_id: + continue + buf = tool_call_buffers.get(call_id) or {} + args_raw = buf.get("arguments") or item.get("arguments") or "{}" + try: + args = json.loads(args_raw) + except Exception: + args = {"raw": args_raw} + tool_calls.append( + ToolCallRequest( + id=f"{call_id}|{buf.get('id') or item.get('id') or 'fc_0'}", + name=buf.get("name") or item.get("name"), + arguments=args, + ) + ) + elif event_type == "response.completed": + status = (event.get("response") or {}).get("status") + finish_reason = map_finish_reason(status) + elif event_type in {"error", "response.failed"}: + raise RuntimeError("Response failed") + + return content, tool_calls, finish_reason + + +def parse_response_output(response: Any) -> LLMResponse: + """Parse an SDK ``Response`` object (from ``client.responses.create()``) + into an ``LLMResponse``. + + Works with both Pydantic model objects and plain dicts. + """ + # Normalise to dict + if not isinstance(response, dict): + dump = getattr(response, "model_dump", None) + response = dump() if callable(dump) else vars(response) + + output = response.get("output") or [] + content_parts: list[str] = [] + tool_calls: list[ToolCallRequest] = [] + + for item in output: + if not isinstance(item, dict): + dump = getattr(item, "model_dump", None) + item = dump() if callable(dump) else vars(item) + + item_type = item.get("type") + if item_type == "message": + for block in item.get("content") or []: + if not isinstance(block, dict): + dump = getattr(block, "model_dump", None) + block = dump() if callable(dump) else vars(block) + if block.get("type") == "output_text": + content_parts.append(block.get("text") or "") + elif item_type == "function_call": + call_id = item.get("call_id") or "" + item_id = item.get("id") or "fc_0" + args_raw = item.get("arguments") or "{}" + try: + args = json.loads(args_raw) if isinstance(args_raw, str) else args_raw + except Exception: + args = {"raw": args_raw} + tool_calls.append(ToolCallRequest( + id=f"{call_id}|{item_id}", + name=item.get("name") or "", + arguments=args if isinstance(args, dict) else {}, + )) + + usage_raw = response.get("usage") or {} + if not isinstance(usage_raw, dict): + dump = getattr(usage_raw, "model_dump", None) + usage_raw = dump() if callable(dump) else vars(usage_raw) + usage = {} + if usage_raw: + usage = { + "prompt_tokens": int(usage_raw.get("input_tokens") or 0), + "completion_tokens": int(usage_raw.get("output_tokens") or 0), + "total_tokens": int(usage_raw.get("total_tokens") or 0), + } + + status = response.get("status") + finish_reason = map_finish_reason(status) + + return LLMResponse( + content="".join(content_parts) or None, + tool_calls=tool_calls, + finish_reason=finish_reason, + usage=usage, + ) diff --git a/tests/providers/test_azure_openai_provider.py b/tests/providers/test_azure_openai_provider.py index 77f36d46..9a95cae5 100644 --- a/tests/providers/test_azure_openai_provider.py +++ b/tests/providers/test_azure_openai_provider.py @@ -1,6 +1,6 @@ -"""Test Azure OpenAI provider implementation (updated for model-based deployment names).""" +"""Test Azure OpenAI provider (Responses API via OpenAI SDK).""" -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -8,392 +8,415 @@ from nanobot.providers.azure_openai_provider import AzureOpenAIProvider from nanobot.providers.base import LLMResponse -def test_azure_openai_provider_init(): - """Test AzureOpenAIProvider initialization without deployment_name.""" +# --------------------------------------------------------------------------- +# Init & validation +# --------------------------------------------------------------------------- + + +def test_init_creates_sdk_client(): + """Provider creates an AsyncOpenAI client with correct base_url.""" provider = AzureOpenAIProvider( api_key="test-key", api_base="https://test-resource.openai.azure.com", default_model="gpt-4o-deployment", ) - assert provider.api_key == "test-key" assert provider.api_base == "https://test-resource.openai.azure.com/" assert provider.default_model == "gpt-4o-deployment" - assert provider.api_version == "2024-10-21" + # SDK client base_url ends with /openai/v1/ + assert str(provider._client.base_url).rstrip("/").endswith("/openai/v1") -def test_azure_openai_provider_init_validation(): - """Test AzureOpenAIProvider initialization validation.""" - # Missing api_key +def test_init_base_url_no_trailing_slash(): + """Trailing slashes are normalised before building base_url.""" + provider = AzureOpenAIProvider( + api_key="k", api_base="https://res.openai.azure.com", + ) + assert str(provider._client.base_url).rstrip("/").endswith("/openai/v1") + + +def test_init_base_url_with_trailing_slash(): + provider = AzureOpenAIProvider( + api_key="k", api_base="https://res.openai.azure.com/", + ) + assert str(provider._client.base_url).rstrip("/").endswith("/openai/v1") + + +def test_init_validation_missing_key(): with pytest.raises(ValueError, match="Azure OpenAI api_key is required"): AzureOpenAIProvider(api_key="", api_base="https://test.com") - - # Missing api_base + + +def test_init_validation_missing_base(): with pytest.raises(ValueError, match="Azure OpenAI api_base is required"): AzureOpenAIProvider(api_key="test", api_base="") -def test_build_chat_url(): - """Test Azure OpenAI URL building with different deployment names.""" +def test_no_api_version_in_base_url(): + """The /openai/v1/ path should NOT contain an api-version query param.""" + provider = AzureOpenAIProvider(api_key="k", api_base="https://res.openai.azure.com") + base = str(provider._client.base_url) + assert "api-version" not in base + + +# --------------------------------------------------------------------------- +# _supports_temperature +# --------------------------------------------------------------------------- + + +def test_supports_temperature_standard_model(): + assert AzureOpenAIProvider._supports_temperature("gpt-4o") is True + + +def test_supports_temperature_reasoning_model(): + assert AzureOpenAIProvider._supports_temperature("o3-mini") is False + assert AzureOpenAIProvider._supports_temperature("gpt-5-chat") is False + assert AzureOpenAIProvider._supports_temperature("o4-mini") is False + + +def test_supports_temperature_with_reasoning_effort(): + assert AzureOpenAIProvider._supports_temperature("gpt-4o", reasoning_effort="medium") is False + + +# --------------------------------------------------------------------------- +# _build_body — Responses API body construction +# --------------------------------------------------------------------------- + + +def test_build_body_basic(): provider = AzureOpenAIProvider( - api_key="test-key", - api_base="https://test-resource.openai.azure.com", - default_model="gpt-4o", + api_key="k", api_base="https://res.openai.azure.com", default_model="gpt-4o", ) - - # Test various deployment names - test_cases = [ - ("gpt-4o-deployment", "https://test-resource.openai.azure.com/openai/deployments/gpt-4o-deployment/chat/completions?api-version=2024-10-21"), - ("gpt-35-turbo", "https://test-resource.openai.azure.com/openai/deployments/gpt-35-turbo/chat/completions?api-version=2024-10-21"), - ("custom-model", "https://test-resource.openai.azure.com/openai/deployments/custom-model/chat/completions?api-version=2024-10-21"), - ] - - for deployment_name, expected_url in test_cases: - url = provider._build_chat_url(deployment_name) - assert url == expected_url + messages = [{"role": "system", "content": "You are helpful."}, {"role": "user", "content": "Hi"}] + body = provider._build_body(messages, None, None, 4096, 0.7, None, None) - -def test_build_chat_url_api_base_without_slash(): - """Test URL building when api_base doesn't end with slash.""" - provider = AzureOpenAIProvider( - api_key="test-key", - api_base="https://test-resource.openai.azure.com", # No trailing slash - default_model="gpt-4o", + assert body["model"] == "gpt-4o" + assert body["instructions"] == "You are helpful." + assert body["temperature"] == 0.7 + assert body["store"] is False + assert "reasoning" not in body + # input should contain the converted user message only (system extracted) + assert any( + item.get("role") == "user" + for item in body["input"] ) - - url = provider._build_chat_url("test-deployment") - expected = "https://test-resource.openai.azure.com/openai/deployments/test-deployment/chat/completions?api-version=2024-10-21" - assert url == expected -def test_build_headers(): - """Test Azure OpenAI header building with api-key authentication.""" - provider = AzureOpenAIProvider( - api_key="test-api-key-123", - api_base="https://test-resource.openai.azure.com", - default_model="gpt-4o", - ) - - headers = provider._build_headers() - assert headers["Content-Type"] == "application/json" - assert headers["api-key"] == "test-api-key-123" # Azure OpenAI specific header - assert "x-session-affinity" in headers - - -def test_prepare_request_payload(): - """Test request payload preparation with Azure OpenAI 2024-10-21 compliance.""" - provider = AzureOpenAIProvider( - api_key="test-key", - api_base="https://test-resource.openai.azure.com", - default_model="gpt-4o", - ) - - messages = [{"role": "user", "content": "Hello"}] - payload = provider._prepare_request_payload("gpt-4o", messages, max_tokens=1500, temperature=0.8) - - assert payload["messages"] == messages - assert payload["max_completion_tokens"] == 1500 # Azure API 2024-10-21 uses max_completion_tokens - assert payload["temperature"] == 0.8 - assert "tools" not in payload - - # Test with tools +def test_build_body_with_tools(): + provider = AzureOpenAIProvider(api_key="k", api_base="https://r.com", default_model="gpt-4o") tools = [{"type": "function", "function": {"name": "get_weather", "parameters": {}}}] - payload_with_tools = provider._prepare_request_payload("gpt-4o", messages, tools=tools) - assert payload_with_tools["tools"] == tools - assert payload_with_tools["tool_choice"] == "auto" - - # Test with reasoning_effort - payload_with_reasoning = provider._prepare_request_payload( - "gpt-5-chat", messages, reasoning_effort="medium" + body = provider._build_body( + [{"role": "user", "content": "weather?"}], tools, None, 4096, 0.7, None, None, ) - assert payload_with_reasoning["reasoning_effort"] == "medium" - assert "temperature" not in payload_with_reasoning + assert body["tools"] == [{"type": "function", "name": "get_weather", "description": "", "parameters": {}}] + assert body["tool_choice"] == "auto" -def test_prepare_request_payload_sanitizes_messages(): - """Test Azure payload strips non-standard message keys before sending.""" - provider = AzureOpenAIProvider( - api_key="test-key", - api_base="https://test-resource.openai.azure.com", - default_model="gpt-4o", +def test_build_body_with_reasoning(): + provider = AzureOpenAIProvider(api_key="k", api_base="https://r.com", default_model="gpt-5-chat") + body = provider._build_body( + [{"role": "user", "content": "think"}], None, "gpt-5-chat", 4096, 0.7, "medium", None, ) + assert body["reasoning"] == {"effort": "medium"} + assert "reasoning.encrypted_content" in body.get("include", []) + # temperature omitted for reasoning models + assert "temperature" not in body - messages = [ - { - "role": "assistant", - "tool_calls": [{"id": "call_123", "type": "function", "function": {"name": "x"}}], - "reasoning_content": "hidden chain-of-thought", - }, - { - "role": "tool", - "tool_call_id": "call_123", - "name": "x", - "content": "ok", - "extra_field": "should be removed", - }, - ] - payload = provider._prepare_request_payload("gpt-4o", messages) +def test_build_body_image_conversion(): + """image_url content blocks should be converted to input_image.""" + provider = AzureOpenAIProvider(api_key="k", api_base="https://r.com", default_model="gpt-4o") + messages = [{ + "role": "user", + "content": [ + {"type": "text", "text": "What's in this image?"}, + {"type": "image_url", "image_url": {"url": "https://example.com/img.png"}}, + ], + }] + body = provider._build_body(messages, None, None, 4096, 0.7, None, None) + user_item = body["input"][0] + content_types = [b["type"] for b in user_item["content"]] + assert "input_text" in content_types + assert "input_image" in content_types + image_block = next(b for b in user_item["content"] if b["type"] == "input_image") + assert image_block["image_url"] == "https://example.com/img.png" - assert payload["messages"] == [ - { - "role": "assistant", - "content": None, - "tool_calls": [{"id": "call_123", "type": "function", "function": {"name": "x"}}], + +# --------------------------------------------------------------------------- +# chat() — non-streaming +# --------------------------------------------------------------------------- + + +def _make_sdk_response( + content="Hello!", tool_calls=None, status="completed", + usage=None, +): + """Build a mock that quacks like an openai Response object.""" + resp = MagicMock() + resp.model_dump = MagicMock(return_value={ + "output": [ + {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": content}]}, + *([{ + "type": "function_call", + "call_id": tc["call_id"], "id": tc["id"], + "name": tc["name"], "arguments": tc["arguments"], + } for tc in (tool_calls or [])]), + ], + "status": status, + "usage": { + "input_tokens": (usage or {}).get("input_tokens", 10), + "output_tokens": (usage or {}).get("output_tokens", 5), + "total_tokens": (usage or {}).get("total_tokens", 15), }, - { - "role": "tool", - "tool_call_id": "call_123", - "name": "x", - "content": "ok", - }, - ] + }) + return resp @pytest.mark.asyncio async def test_chat_success(): - """Test successful chat request using model as deployment name.""" provider = AzureOpenAIProvider( - api_key="test-key", - api_base="https://test-resource.openai.azure.com", - default_model="gpt-4o-deployment", + api_key="test-key", api_base="https://test.openai.azure.com", default_model="gpt-4o", ) - - # Mock response data - mock_response_data = { - "choices": [{ - "message": { - "content": "Hello! How can I help you today?", - "role": "assistant" - }, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": 12, - "completion_tokens": 18, - "total_tokens": 30 - } - } - - with patch("httpx.AsyncClient") as mock_client: - mock_response = AsyncMock() - mock_response.status_code = 200 - mock_response.json = Mock(return_value=mock_response_data) - - mock_context = AsyncMock() - mock_context.post = AsyncMock(return_value=mock_response) - mock_client.return_value.__aenter__.return_value = mock_context - - # Test with specific model (deployment name) - messages = [{"role": "user", "content": "Hello"}] - result = await provider.chat(messages, model="custom-deployment") - - assert isinstance(result, LLMResponse) - assert result.content == "Hello! How can I help you today?" - assert result.finish_reason == "stop" - assert result.usage["prompt_tokens"] == 12 - assert result.usage["completion_tokens"] == 18 - assert result.usage["total_tokens"] == 30 - - # Verify URL was built with the provided model as deployment name - call_args = mock_context.post.call_args - expected_url = "https://test-resource.openai.azure.com/openai/deployments/custom-deployment/chat/completions?api-version=2024-10-21" - assert call_args[0][0] == expected_url + mock_resp = _make_sdk_response(content="Hello!") + provider._client.responses = MagicMock() + provider._client.responses.create = AsyncMock(return_value=mock_resp) + + result = await provider.chat([{"role": "user", "content": "Hi"}]) + + assert isinstance(result, LLMResponse) + assert result.content == "Hello!" + assert result.finish_reason == "stop" + assert result.usage["prompt_tokens"] == 10 @pytest.mark.asyncio -async def test_chat_uses_default_model_when_no_model_provided(): - """Test that chat uses default_model when no model is specified.""" +async def test_chat_uses_default_model(): provider = AzureOpenAIProvider( - api_key="test-key", - api_base="https://test-resource.openai.azure.com", - default_model="default-deployment", + api_key="k", api_base="https://test.openai.azure.com", default_model="my-deployment", ) - - mock_response_data = { - "choices": [{ - "message": {"content": "Response", "role": "assistant"}, - "finish_reason": "stop" - }], - "usage": {"prompt_tokens": 5, "completion_tokens": 5, "total_tokens": 10} - } - - with patch("httpx.AsyncClient") as mock_client: - mock_response = AsyncMock() - mock_response.status_code = 200 - mock_response.json = Mock(return_value=mock_response_data) - - mock_context = AsyncMock() - mock_context.post = AsyncMock(return_value=mock_response) - mock_client.return_value.__aenter__.return_value = mock_context - - messages = [{"role": "user", "content": "Test"}] - await provider.chat(messages) # No model specified - - # Verify URL was built with default model as deployment name - call_args = mock_context.post.call_args - expected_url = "https://test-resource.openai.azure.com/openai/deployments/default-deployment/chat/completions?api-version=2024-10-21" - assert call_args[0][0] == expected_url + mock_resp = _make_sdk_response(content="ok") + provider._client.responses = MagicMock() + provider._client.responses.create = AsyncMock(return_value=mock_resp) + + await provider.chat([{"role": "user", "content": "test"}]) + + call_kwargs = provider._client.responses.create.call_args[1] + assert call_kwargs["model"] == "my-deployment" + + +@pytest.mark.asyncio +async def test_chat_custom_model(): + provider = AzureOpenAIProvider( + api_key="k", api_base="https://test.openai.azure.com", default_model="gpt-4o", + ) + mock_resp = _make_sdk_response(content="ok") + provider._client.responses = MagicMock() + provider._client.responses.create = AsyncMock(return_value=mock_resp) + + await provider.chat([{"role": "user", "content": "test"}], model="custom-deploy") + + call_kwargs = provider._client.responses.create.call_args[1] + assert call_kwargs["model"] == "custom-deploy" @pytest.mark.asyncio async def test_chat_with_tool_calls(): - """Test chat request with tool calls in response.""" provider = AzureOpenAIProvider( - api_key="test-key", - api_base="https://test-resource.openai.azure.com", - default_model="gpt-4o", + api_key="k", api_base="https://test.openai.azure.com", default_model="gpt-4o", ) - - # Mock response with tool calls - mock_response_data = { - "choices": [{ - "message": { - "content": None, - "role": "assistant", - "tool_calls": [{ - "id": "call_12345", - "function": { - "name": "get_weather", - "arguments": '{"location": "San Francisco"}' - } - }] - }, - "finish_reason": "tool_calls" + mock_resp = _make_sdk_response( + content=None, + tool_calls=[{ + "call_id": "call_123", "id": "fc_1", + "name": "get_weather", "arguments": '{"location": "SF"}', }], - "usage": { - "prompt_tokens": 20, - "completion_tokens": 15, - "total_tokens": 35 - } - } - - with patch("httpx.AsyncClient") as mock_client: - mock_response = AsyncMock() - mock_response.status_code = 200 - mock_response.json = Mock(return_value=mock_response_data) - - mock_context = AsyncMock() - mock_context.post = AsyncMock(return_value=mock_response) - mock_client.return_value.__aenter__.return_value = mock_context - - messages = [{"role": "user", "content": "What's the weather?"}] - tools = [{"type": "function", "function": {"name": "get_weather", "parameters": {}}}] - result = await provider.chat(messages, tools=tools, model="weather-model") - - assert isinstance(result, LLMResponse) - assert result.content is None - assert result.finish_reason == "tool_calls" - assert len(result.tool_calls) == 1 - assert result.tool_calls[0].name == "get_weather" - assert result.tool_calls[0].arguments == {"location": "San Francisco"} + ) + provider._client.responses = MagicMock() + provider._client.responses.create = AsyncMock(return_value=mock_resp) + + result = await provider.chat( + [{"role": "user", "content": "Weather?"}], + tools=[{"type": "function", "function": {"name": "get_weather", "parameters": {}}}], + ) + + assert len(result.tool_calls) == 1 + assert result.tool_calls[0].name == "get_weather" + assert result.tool_calls[0].arguments == {"location": "SF"} @pytest.mark.asyncio -async def test_chat_api_error(): - """Test chat request API error handling.""" +async def test_chat_error_handling(): provider = AzureOpenAIProvider( - api_key="test-key", - api_base="https://test-resource.openai.azure.com", - default_model="gpt-4o", + api_key="k", api_base="https://test.openai.azure.com", default_model="gpt-4o", ) - - with patch("httpx.AsyncClient") as mock_client: - mock_response = AsyncMock() - mock_response.status_code = 401 - mock_response.text = "Invalid authentication credentials" - - mock_context = AsyncMock() - mock_context.post = AsyncMock(return_value=mock_response) - mock_client.return_value.__aenter__.return_value = mock_context - - messages = [{"role": "user", "content": "Hello"}] - result = await provider.chat(messages) - - assert isinstance(result, LLMResponse) - assert "Azure OpenAI API Error 401" in result.content - assert "Invalid authentication credentials" in result.content - assert result.finish_reason == "error" + provider._client.responses = MagicMock() + provider._client.responses.create = AsyncMock(side_effect=Exception("Connection failed")) + result = await provider.chat([{"role": "user", "content": "Hi"}]) -@pytest.mark.asyncio -async def test_chat_connection_error(): - """Test chat request connection error handling.""" - provider = AzureOpenAIProvider( - api_key="test-key", - api_base="https://test-resource.openai.azure.com", - default_model="gpt-4o", - ) - - with patch("httpx.AsyncClient") as mock_client: - mock_context = AsyncMock() - mock_context.post = AsyncMock(side_effect=Exception("Connection failed")) - mock_client.return_value.__aenter__.return_value = mock_context - - messages = [{"role": "user", "content": "Hello"}] - result = await provider.chat(messages) - - assert isinstance(result, LLMResponse) - assert "Error calling Azure OpenAI: Exception('Connection failed')" in result.content - assert result.finish_reason == "error" - - -def test_parse_response_malformed(): - """Test response parsing with malformed data.""" - provider = AzureOpenAIProvider( - api_key="test-key", - api_base="https://test-resource.openai.azure.com", - default_model="gpt-4o", - ) - - # Test with missing choices - malformed_response = {"usage": {"prompt_tokens": 10}} - result = provider._parse_response(malformed_response) - assert isinstance(result, LLMResponse) - assert "Error parsing Azure OpenAI response" in result.content + assert "Connection failed" in result.content assert result.finish_reason == "error" +@pytest.mark.asyncio +async def test_chat_reasoning_param_format(): + """reasoning_effort should be sent as reasoning={effort: ...} not a flat string.""" + provider = AzureOpenAIProvider( + api_key="k", api_base="https://test.openai.azure.com", default_model="gpt-5-chat", + ) + mock_resp = _make_sdk_response(content="thought") + provider._client.responses = MagicMock() + provider._client.responses.create = AsyncMock(return_value=mock_resp) + + await provider.chat( + [{"role": "user", "content": "think"}], reasoning_effort="medium", + ) + + call_kwargs = provider._client.responses.create.call_args[1] + assert call_kwargs["reasoning"] == {"effort": "medium"} + assert "reasoning_effort" not in call_kwargs + + +# --------------------------------------------------------------------------- +# chat_stream() +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_chat_stream_success(): + """Streaming should call on_content_delta and return combined response.""" + provider = AzureOpenAIProvider( + api_key="test-key", api_base="https://test.openai.azure.com", default_model="gpt-4o", + ) + + # Build SSE lines for the mock httpx stream + sse_events = [ + 'event: response.output_text.delta', + 'data: {"type":"response.output_text.delta","delta":"Hello"}', + '', + 'event: response.output_text.delta', + 'data: {"type":"response.output_text.delta","delta":" world"}', + '', + 'event: response.completed', + 'data: {"type":"response.completed","response":{"status":"completed"}}', + '', + ] + + deltas: list[str] = [] + + async def on_delta(text: str) -> None: + deltas.append(text) + + # Mock httpx stream + mock_response = AsyncMock() + mock_response.status_code = 200 + + async def aiter_lines(): + for line in sse_events: + yield line + + mock_response.aiter_lines = aiter_lines + + with patch("httpx.AsyncClient") as mock_client: + mock_ctx = AsyncMock() + mock_stream_ctx = AsyncMock() + mock_stream_ctx.__aenter__ = AsyncMock(return_value=mock_response) + mock_stream_ctx.__aexit__ = AsyncMock(return_value=False) + mock_ctx.stream = MagicMock(return_value=mock_stream_ctx) + mock_client.return_value.__aenter__ = AsyncMock(return_value=mock_ctx) + mock_client.return_value.__aexit__ = AsyncMock(return_value=False) + + result = await provider.chat_stream( + [{"role": "user", "content": "Hi"}], on_content_delta=on_delta, + ) + + assert result.content == "Hello world" + assert result.finish_reason == "stop" + assert deltas == ["Hello", " world"] + + +@pytest.mark.asyncio +async def test_chat_stream_with_tool_calls(): + """Streaming tool calls should be accumulated correctly.""" + provider = AzureOpenAIProvider( + api_key="k", api_base="https://test.openai.azure.com", default_model="gpt-4o", + ) + + sse_events = [ + 'data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_1","id":"fc_1","name":"get_weather","arguments":""}}', + '', + 'data: {"type":"response.function_call_arguments.delta","call_id":"call_1","delta":"{\\"loc"}', + '', + 'data: {"type":"response.function_call_arguments.done","call_id":"call_1","arguments":"{\\"location\\":\\"SF\\"}"}', + '', + 'data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_1","id":"fc_1","name":"get_weather","arguments":"{\\"location\\":\\"SF\\"}"}}', + '', + 'data: {"type":"response.completed","response":{"status":"completed"}}', + '', + ] + + mock_response = AsyncMock() + mock_response.status_code = 200 + + async def aiter_lines(): + for line in sse_events: + yield line + + mock_response.aiter_lines = aiter_lines + + with patch("httpx.AsyncClient") as mock_client: + mock_ctx = AsyncMock() + mock_stream_ctx = AsyncMock() + mock_stream_ctx.__aenter__ = AsyncMock(return_value=mock_response) + mock_stream_ctx.__aexit__ = AsyncMock(return_value=False) + mock_ctx.stream = MagicMock(return_value=mock_stream_ctx) + mock_client.return_value.__aenter__ = AsyncMock(return_value=mock_ctx) + mock_client.return_value.__aexit__ = AsyncMock(return_value=False) + + result = await provider.chat_stream( + [{"role": "user", "content": "weather?"}], + tools=[{"type": "function", "function": {"name": "get_weather", "parameters": {}}}], + ) + + assert len(result.tool_calls) == 1 + assert result.tool_calls[0].name == "get_weather" + assert result.tool_calls[0].arguments == {"location": "SF"} + + +@pytest.mark.asyncio +async def test_chat_stream_http_error(): + """Streaming should return error on non-200 status.""" + provider = AzureOpenAIProvider( + api_key="k", api_base="https://test.openai.azure.com", default_model="gpt-4o", + ) + + mock_response = AsyncMock() + mock_response.status_code = 401 + mock_response.aread = AsyncMock(return_value=b"Unauthorized") + + with patch("httpx.AsyncClient") as mock_client: + mock_ctx = AsyncMock() + mock_stream_ctx = AsyncMock() + mock_stream_ctx.__aenter__ = AsyncMock(return_value=mock_response) + mock_stream_ctx.__aexit__ = AsyncMock(return_value=False) + mock_ctx.stream = MagicMock(return_value=mock_stream_ctx) + mock_client.return_value.__aenter__ = AsyncMock(return_value=mock_ctx) + mock_client.return_value.__aexit__ = AsyncMock(return_value=False) + + result = await provider.chat_stream([{"role": "user", "content": "Hi"}]) + + assert "401" in result.content + assert result.finish_reason == "error" + + +# --------------------------------------------------------------------------- +# get_default_model +# --------------------------------------------------------------------------- + + def test_get_default_model(): - """Test get_default_model method.""" provider = AzureOpenAIProvider( - api_key="test-key", - api_base="https://test-resource.openai.azure.com", - default_model="my-custom-deployment", + api_key="k", api_base="https://r.com", default_model="my-deploy", ) - - assert provider.get_default_model() == "my-custom-deployment" - - -if __name__ == "__main__": - # Run basic tests - print("Running basic Azure OpenAI provider tests...") - - # Test initialization - provider = AzureOpenAIProvider( - api_key="test-key", - api_base="https://test-resource.openai.azure.com", - default_model="gpt-4o-deployment", - ) - print("✅ Provider initialization successful") - - # Test URL building - url = provider._build_chat_url("my-deployment") - expected = "https://test-resource.openai.azure.com/openai/deployments/my-deployment/chat/completions?api-version=2024-10-21" - assert url == expected - print("✅ URL building works correctly") - - # Test headers - headers = provider._build_headers() - assert headers["api-key"] == "test-key" - assert headers["Content-Type"] == "application/json" - print("✅ Header building works correctly") - - # Test payload preparation - messages = [{"role": "user", "content": "Test"}] - payload = provider._prepare_request_payload("gpt-4o-deployment", messages, max_tokens=1000) - assert payload["max_completion_tokens"] == 1000 # Azure 2024-10-21 format - print("✅ Payload preparation works correctly") - - print("✅ All basic tests passed! Updated test file is working correctly.") \ No newline at end of file + assert provider.get_default_model() == "my-deploy" From 8c0607e079eff78932c3d45013164975501cfe64 Mon Sep 17 00:00:00 2001 From: Kunal Karmakar Date: Tue, 31 Mar 2026 02:17:30 +0000 Subject: [PATCH 64/91] Use SDK for stream --- nanobot/providers/azure_openai_provider.py | 38 ++--- .../openai_responses_common/__init__.py | 2 + .../openai_responses_common/parsing.py | 69 +++++++++ tests/providers/test_azure_openai_provider.py | 141 +++++++----------- 4 files changed, 139 insertions(+), 111 deletions(-) diff --git a/nanobot/providers/azure_openai_provider.py b/nanobot/providers/azure_openai_provider.py index ab4d187a..b97743ab 100644 --- a/nanobot/providers/azure_openai_provider.py +++ b/nanobot/providers/azure_openai_provider.py @@ -11,12 +11,11 @@ import uuid from collections.abc import Awaitable, Callable from typing import Any -import httpx from openai import AsyncOpenAI from nanobot.providers.base import LLMProvider, LLMResponse from nanobot.providers.openai_responses_common import ( - consume_sse, + consume_sdk_stream, convert_messages, convert_tools, parse_response_output, @@ -94,6 +93,7 @@ class AzureOpenAIProvider(LLMProvider): "model": deployment, "instructions": instructions or None, "input": input_items, + "max_output_tokens": max(1, max_tokens), "store": False, "stream": False, } @@ -159,31 +159,15 @@ class AzureOpenAIProvider(LLMProvider): body["stream"] = True try: - # Use raw httpx stream via the SDK's base URL so we can reuse - # the shared Responses-API SSE parser (same as Codex provider). - base_url = str(self._client.base_url).rstrip("/") - url = f"{base_url}/responses" - headers = { - "Authorization": f"Bearer {self._client.api_key}", - "Content-Type": "application/json", - **(self._client._custom_headers or {}), - } - async with httpx.AsyncClient(timeout=60.0, verify=True) as http: - async with http.stream("POST", url, headers=headers, json=body) as response: - if response.status_code != 200: - text = await response.aread() - return LLMResponse( - content=f"Azure OpenAI API Error {response.status_code}: {text.decode('utf-8', 'ignore')}", - finish_reason="error", - ) - content, tool_calls, finish_reason = await consume_sse( - response, on_content_delta, - ) - return LLMResponse( - content=content or None, - tool_calls=tool_calls, - finish_reason=finish_reason, - ) + stream = await self._client.responses.create(**body) + content, tool_calls, finish_reason = await consume_sdk_stream( + stream, on_content_delta, + ) + return LLMResponse( + content=content or None, + tool_calls=tool_calls, + finish_reason=finish_reason, + ) except Exception as e: return self._handle_error(e) diff --git a/nanobot/providers/openai_responses_common/__init__.py b/nanobot/providers/openai_responses_common/__init__.py index cfc327bd..80a03e43 100644 --- a/nanobot/providers/openai_responses_common/__init__.py +++ b/nanobot/providers/openai_responses_common/__init__.py @@ -8,6 +8,7 @@ from nanobot.providers.openai_responses_common.converters import ( ) from nanobot.providers.openai_responses_common.parsing import ( FINISH_REASON_MAP, + consume_sdk_stream, consume_sse, iter_sse, map_finish_reason, @@ -21,6 +22,7 @@ __all__ = [ "split_tool_call_id", "iter_sse", "consume_sse", + "consume_sdk_stream", "map_finish_reason", "parse_response_output", "FINISH_REASON_MAP", diff --git a/nanobot/providers/openai_responses_common/parsing.py b/nanobot/providers/openai_responses_common/parsing.py index e0d5f446..5de89553 100644 --- a/nanobot/providers/openai_responses_common/parsing.py +++ b/nanobot/providers/openai_responses_common/parsing.py @@ -171,3 +171,72 @@ def parse_response_output(response: Any) -> LLMResponse: finish_reason=finish_reason, usage=usage, ) + + +async def consume_sdk_stream( + stream: Any, + on_content_delta: Callable[[str], Awaitable[None]] | None = None, +) -> tuple[str, list[ToolCallRequest], str]: + """Consume an SDK async stream from ``client.responses.create(stream=True)``. + + The SDK yields typed event objects with a ``.type`` attribute and + event-specific fields. Returns ``(content, tool_calls, finish_reason)``. + """ + content = "" + tool_calls: list[ToolCallRequest] = [] + tool_call_buffers: dict[str, dict[str, Any]] = {} + finish_reason = "stop" + + async for event in stream: + event_type = getattr(event, "type", None) + if event_type == "response.output_item.added": + item = getattr(event, "item", None) + if item and getattr(item, "type", None) == "function_call": + call_id = getattr(item, "call_id", None) + if not call_id: + continue + tool_call_buffers[call_id] = { + "id": getattr(item, "id", None) or "fc_0", + "name": getattr(item, "name", None), + "arguments": getattr(item, "arguments", None) or "", + } + elif event_type == "response.output_text.delta": + delta_text = getattr(event, "delta", "") or "" + content += delta_text + if on_content_delta and delta_text: + await on_content_delta(delta_text) + elif event_type == "response.function_call_arguments.delta": + call_id = getattr(event, "call_id", None) + if call_id and call_id in tool_call_buffers: + tool_call_buffers[call_id]["arguments"] += getattr(event, "delta", "") or "" + elif event_type == "response.function_call_arguments.done": + call_id = getattr(event, "call_id", None) + if call_id and call_id in tool_call_buffers: + tool_call_buffers[call_id]["arguments"] = getattr(event, "arguments", "") or "" + elif event_type == "response.output_item.done": + item = getattr(event, "item", None) + if item and getattr(item, "type", None) == "function_call": + call_id = getattr(item, "call_id", None) + if not call_id: + continue + buf = tool_call_buffers.get(call_id) or {} + args_raw = buf.get("arguments") or getattr(item, "arguments", None) or "{}" + try: + args = json.loads(args_raw) + except Exception: + args = {"raw": args_raw} + tool_calls.append( + ToolCallRequest( + id=f"{call_id}|{buf.get('id') or getattr(item, 'id', None) or 'fc_0'}", + name=buf.get("name") or getattr(item, "name", None), + arguments=args, + ) + ) + elif event_type == "response.completed": + resp = getattr(event, "response", None) + status = getattr(resp, "status", None) if resp else None + finish_reason = map_finish_reason(status) + elif event_type in {"error", "response.failed"}: + raise RuntimeError("Response failed") + + return content, tool_calls, finish_reason diff --git a/tests/providers/test_azure_openai_provider.py b/tests/providers/test_azure_openai_provider.py index 9a95cae5..4a18f3bf 100644 --- a/tests/providers/test_azure_openai_provider.py +++ b/tests/providers/test_azure_openai_provider.py @@ -1,6 +1,6 @@ """Test Azure OpenAI provider (Responses API via OpenAI SDK).""" -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock import pytest @@ -93,6 +93,7 @@ def test_build_body_basic(): assert body["model"] == "gpt-4o" assert body["instructions"] == "You are helpful." assert body["temperature"] == 0.7 + assert body["max_output_tokens"] == 4096 assert body["store"] is False assert "reasoning" not in body # input should contain the converted user message only (system extracted) @@ -102,6 +103,13 @@ def test_build_body_basic(): ) +def test_build_body_max_tokens_minimum(): + """max_output_tokens should never be less than 1.""" + provider = AzureOpenAIProvider(api_key="k", api_base="https://r.com", default_model="gpt-4o") + body = provider._build_body([{"role": "user", "content": "x"}], None, None, 0, 0.7, None, None) + assert body["max_output_tokens"] == 1 + + def test_build_body_with_tools(): provider = AzureOpenAIProvider(api_key="k", api_base="https://r.com", default_model="gpt-4o") tools = [{"type": "function", "function": {"name": "get_weather", "parameters": {}}}] @@ -290,46 +298,29 @@ async def test_chat_stream_success(): api_key="test-key", api_base="https://test.openai.azure.com", default_model="gpt-4o", ) - # Build SSE lines for the mock httpx stream - sse_events = [ - 'event: response.output_text.delta', - 'data: {"type":"response.output_text.delta","delta":"Hello"}', - '', - 'event: response.output_text.delta', - 'data: {"type":"response.output_text.delta","delta":" world"}', - '', - 'event: response.completed', - 'data: {"type":"response.completed","response":{"status":"completed"}}', - '', - ] + # Build mock SDK stream events + events = [] + ev1 = MagicMock(type="response.output_text.delta", delta="Hello") + ev2 = MagicMock(type="response.output_text.delta", delta=" world") + resp_obj = MagicMock(status="completed") + ev3 = MagicMock(type="response.completed", response=resp_obj) + events = [ev1, ev2, ev3] + + async def mock_stream(): + for e in events: + yield e + + provider._client.responses = MagicMock() + provider._client.responses.create = AsyncMock(return_value=mock_stream()) deltas: list[str] = [] async def on_delta(text: str) -> None: deltas.append(text) - # Mock httpx stream - mock_response = AsyncMock() - mock_response.status_code = 200 - - async def aiter_lines(): - for line in sse_events: - yield line - - mock_response.aiter_lines = aiter_lines - - with patch("httpx.AsyncClient") as mock_client: - mock_ctx = AsyncMock() - mock_stream_ctx = AsyncMock() - mock_stream_ctx.__aenter__ = AsyncMock(return_value=mock_response) - mock_stream_ctx.__aexit__ = AsyncMock(return_value=False) - mock_ctx.stream = MagicMock(return_value=mock_stream_ctx) - mock_client.return_value.__aenter__ = AsyncMock(return_value=mock_ctx) - mock_client.return_value.__aexit__ = AsyncMock(return_value=False) - - result = await provider.chat_stream( - [{"role": "user", "content": "Hi"}], on_content_delta=on_delta, - ) + result = await provider.chat_stream( + [{"role": "user", "content": "Hi"}], on_content_delta=on_delta, + ) assert result.content == "Hello world" assert result.finish_reason == "stop" @@ -343,41 +334,34 @@ async def test_chat_stream_with_tool_calls(): api_key="k", api_base="https://test.openai.azure.com", default_model="gpt-4o", ) - sse_events = [ - 'data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_1","id":"fc_1","name":"get_weather","arguments":""}}', - '', - 'data: {"type":"response.function_call_arguments.delta","call_id":"call_1","delta":"{\\"loc"}', - '', - 'data: {"type":"response.function_call_arguments.done","call_id":"call_1","arguments":"{\\"location\\":\\"SF\\"}"}', - '', - 'data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_1","id":"fc_1","name":"get_weather","arguments":"{\\"location\\":\\"SF\\"}"}}', - '', - 'data: {"type":"response.completed","response":{"status":"completed"}}', - '', - ] + item_added = MagicMock(type="function_call", call_id="call_1", id="fc_1", arguments="") + item_added.name = "get_weather" + ev_added = MagicMock(type="response.output_item.added", item=item_added) + ev_args_delta = MagicMock(type="response.function_call_arguments.delta", call_id="call_1", delta='{"loc') + ev_args_done = MagicMock( + type="response.function_call_arguments.done", + call_id="call_1", arguments='{"location":"SF"}', + ) + item_done = MagicMock( + type="function_call", call_id="call_1", id="fc_1", + arguments='{"location":"SF"}', + ) + item_done.name = "get_weather" + ev_item_done = MagicMock(type="response.output_item.done", item=item_done) + resp_obj = MagicMock(status="completed") + ev_completed = MagicMock(type="response.completed", response=resp_obj) - mock_response = AsyncMock() - mock_response.status_code = 200 + async def mock_stream(): + for e in [ev_added, ev_args_delta, ev_args_done, ev_item_done, ev_completed]: + yield e - async def aiter_lines(): - for line in sse_events: - yield line + provider._client.responses = MagicMock() + provider._client.responses.create = AsyncMock(return_value=mock_stream()) - mock_response.aiter_lines = aiter_lines - - with patch("httpx.AsyncClient") as mock_client: - mock_ctx = AsyncMock() - mock_stream_ctx = AsyncMock() - mock_stream_ctx.__aenter__ = AsyncMock(return_value=mock_response) - mock_stream_ctx.__aexit__ = AsyncMock(return_value=False) - mock_ctx.stream = MagicMock(return_value=mock_stream_ctx) - mock_client.return_value.__aenter__ = AsyncMock(return_value=mock_ctx) - mock_client.return_value.__aexit__ = AsyncMock(return_value=False) - - result = await provider.chat_stream( - [{"role": "user", "content": "weather?"}], - tools=[{"type": "function", "function": {"name": "get_weather", "parameters": {}}}], - ) + result = await provider.chat_stream( + [{"role": "user", "content": "weather?"}], + tools=[{"type": "function", "function": {"name": "get_weather", "parameters": {}}}], + ) assert len(result.tool_calls) == 1 assert result.tool_calls[0].name == "get_weather" @@ -385,28 +369,17 @@ async def test_chat_stream_with_tool_calls(): @pytest.mark.asyncio -async def test_chat_stream_http_error(): - """Streaming should return error on non-200 status.""" +async def test_chat_stream_error(): + """Streaming should return error when SDK raises.""" provider = AzureOpenAIProvider( api_key="k", api_base="https://test.openai.azure.com", default_model="gpt-4o", ) + provider._client.responses = MagicMock() + provider._client.responses.create = AsyncMock(side_effect=Exception("Connection failed")) - mock_response = AsyncMock() - mock_response.status_code = 401 - mock_response.aread = AsyncMock(return_value=b"Unauthorized") + result = await provider.chat_stream([{"role": "user", "content": "Hi"}]) - with patch("httpx.AsyncClient") as mock_client: - mock_ctx = AsyncMock() - mock_stream_ctx = AsyncMock() - mock_stream_ctx.__aenter__ = AsyncMock(return_value=mock_response) - mock_stream_ctx.__aexit__ = AsyncMock(return_value=False) - mock_ctx.stream = MagicMock(return_value=mock_stream_ctx) - mock_client.return_value.__aenter__ = AsyncMock(return_value=mock_ctx) - mock_client.return_value.__aexit__ = AsyncMock(return_value=False) - - result = await provider.chat_stream([{"role": "user", "content": "Hi"}]) - - assert "401" in result.content + assert "Connection failed" in result.content assert result.finish_reason == "error" From 7c44aa92ca42847fcf6d01b150a36daa740e3548 Mon Sep 17 00:00:00 2001 From: Kunal Karmakar Date: Tue, 31 Mar 2026 02:29:40 +0000 Subject: [PATCH 65/91] Fill up gaps --- nanobot/providers/azure_openai_provider.py | 6 ++-- .../openai_responses_common/parsing.py | 36 +++++++++++++++++-- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/nanobot/providers/azure_openai_provider.py b/nanobot/providers/azure_openai_provider.py index b97743ab..f2f63a5b 100644 --- a/nanobot/providers/azure_openai_provider.py +++ b/nanobot/providers/azure_openai_provider.py @@ -160,13 +160,15 @@ class AzureOpenAIProvider(LLMProvider): try: stream = await self._client.responses.create(**body) - content, tool_calls, finish_reason = await consume_sdk_stream( - stream, on_content_delta, + content, tool_calls, finish_reason, usage, reasoning_content = ( + await consume_sdk_stream(stream, on_content_delta) ) return LLMResponse( content=content or None, tool_calls=tool_calls, finish_reason=finish_reason, + usage=usage, + reasoning_content=reasoning_content, ) except Exception as e: return self._handle_error(e) diff --git a/nanobot/providers/openai_responses_common/parsing.py b/nanobot/providers/openai_responses_common/parsing.py index 5de89553..df59babd 100644 --- a/nanobot/providers/openai_responses_common/parsing.py +++ b/nanobot/providers/openai_responses_common/parsing.py @@ -122,6 +122,7 @@ def parse_response_output(response: Any) -> LLMResponse: output = response.get("output") or [] content_parts: list[str] = [] tool_calls: list[ToolCallRequest] = [] + reasoning_content: str | None = None for item in output: if not isinstance(item, dict): @@ -136,6 +137,14 @@ def parse_response_output(response: Any) -> LLMResponse: block = dump() if callable(dump) else vars(block) if block.get("type") == "output_text": content_parts.append(block.get("text") or "") + elif item_type == "reasoning": + # Reasoning items may have a summary list with text blocks + for s in item.get("summary") or []: + if not isinstance(s, dict): + dump = getattr(s, "model_dump", None) + s = dump() if callable(dump) else vars(s) + if s.get("type") == "summary_text" and s.get("text"): + reasoning_content = (reasoning_content or "") + s["text"] elif item_type == "function_call": call_id = item.get("call_id") or "" item_id = item.get("id") or "fc_0" @@ -170,22 +179,26 @@ def parse_response_output(response: Any) -> LLMResponse: tool_calls=tool_calls, finish_reason=finish_reason, usage=usage, + reasoning_content=reasoning_content if isinstance(reasoning_content, str) else None, ) async def consume_sdk_stream( stream: Any, on_content_delta: Callable[[str], Awaitable[None]] | None = None, -) -> tuple[str, list[ToolCallRequest], str]: +) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]: """Consume an SDK async stream from ``client.responses.create(stream=True)``. The SDK yields typed event objects with a ``.type`` attribute and - event-specific fields. Returns ``(content, tool_calls, finish_reason)``. + event-specific fields. Returns + ``(content, tool_calls, finish_reason, usage, reasoning_content)``. """ content = "" tool_calls: list[ToolCallRequest] = [] tool_call_buffers: dict[str, dict[str, Any]] = {} finish_reason = "stop" + usage: dict[str, int] = {} + reasoning_content: str | None = None async for event in stream: event_type = getattr(event, "type", None) @@ -236,7 +249,24 @@ async def consume_sdk_stream( resp = getattr(event, "response", None) status = getattr(resp, "status", None) if resp else None finish_reason = map_finish_reason(status) + # Extract usage from the completed response + if resp: + usage_obj = getattr(resp, "usage", None) + if usage_obj: + usage = { + "prompt_tokens": int(getattr(usage_obj, "input_tokens", 0) or 0), + "completion_tokens": int(getattr(usage_obj, "output_tokens", 0) or 0), + "total_tokens": int(getattr(usage_obj, "total_tokens", 0) or 0), + } + # Extract reasoning_content from completed output items + for out_item in getattr(resp, "output", None) or []: + if getattr(out_item, "type", None) == "reasoning": + for s in getattr(out_item, "summary", None) or []: + if getattr(s, "type", None) == "summary_text": + text = getattr(s, "text", None) + if text: + reasoning_content = (reasoning_content or "") + text elif event_type in {"error", "response.failed"}: raise RuntimeError("Response failed") - return content, tool_calls, finish_reason + return content, tool_calls, finish_reason, usage, reasoning_content From ac2ee587914bc042cf41f8c9e88b1f7024e4448f Mon Sep 17 00:00:00 2001 From: Kunal Karmakar Date: Tue, 31 Mar 2026 08:30:11 +0000 Subject: [PATCH 66/91] Add tests and logs --- .../openai_responses_common/parsing.py | 9 + .../providers/test_openai_responses_common.py | 532 ++++++++++++++++++ 2 files changed, 541 insertions(+) create mode 100644 tests/providers/test_openai_responses_common.py diff --git a/nanobot/providers/openai_responses_common/parsing.py b/nanobot/providers/openai_responses_common/parsing.py index df59babd..1e38fdc4 100644 --- a/nanobot/providers/openai_responses_common/parsing.py +++ b/nanobot/providers/openai_responses_common/parsing.py @@ -7,6 +7,7 @@ from collections.abc import Awaitable, Callable from typing import Any, AsyncGenerator import httpx +from loguru import logger from nanobot.providers.base import LLMResponse, ToolCallRequest @@ -39,6 +40,7 @@ async def iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any], N try: yield json.loads(data) except Exception: + logger.warning("Failed to parse SSE event JSON: {}", data[:200]) continue continue buffer.append(line) @@ -91,6 +93,8 @@ async def consume_sse( try: args = json.loads(args_raw) except Exception: + logger.warning("Failed to parse tool call arguments for '{}': {}", + buf.get("name") or item.get("name"), args_raw[:200]) args = {"raw": args_raw} tool_calls.append( ToolCallRequest( @@ -152,6 +156,8 @@ def parse_response_output(response: Any) -> LLMResponse: try: args = json.loads(args_raw) if isinstance(args_raw, str) else args_raw except Exception: + logger.warning("Failed to parse tool call arguments for '{}': {}", + item.get("name"), str(args_raw)[:200]) args = {"raw": args_raw} tool_calls.append(ToolCallRequest( id=f"{call_id}|{item_id}", @@ -237,6 +243,9 @@ async def consume_sdk_stream( try: args = json.loads(args_raw) except Exception: + logger.warning("Failed to parse tool call arguments for '{}': {}", + buf.get("name") or getattr(item, "name", None), + str(args_raw)[:200]) args = {"raw": args_raw} tool_calls.append( ToolCallRequest( diff --git a/tests/providers/test_openai_responses_common.py b/tests/providers/test_openai_responses_common.py new file mode 100644 index 00000000..aa972f08 --- /dev/null +++ b/tests/providers/test_openai_responses_common.py @@ -0,0 +1,532 @@ +"""Tests for the shared openai_responses_common converters and parsers.""" + +from unittest.mock import MagicMock + +import pytest +from loguru import logger + +from nanobot.providers.base import LLMResponse, ToolCallRequest +from nanobot.providers.openai_responses_common.converters import ( + convert_messages, + convert_tools, + convert_user_message, + split_tool_call_id, +) +from nanobot.providers.openai_responses_common.parsing import ( + consume_sdk_stream, + map_finish_reason, + parse_response_output, +) + + +@pytest.fixture() +def loguru_capture(): + """Capture loguru messages into a list for assertion.""" + messages: list[str] = [] + + def sink(message): + messages.append(str(message)) + + handler_id = logger.add(sink, format="{message}", level="DEBUG") + yield messages + logger.remove(handler_id) + + +# ====================================================================== +# converters — split_tool_call_id +# ====================================================================== + + +class TestSplitToolCallId: + def test_plain_id(self): + assert split_tool_call_id("call_abc") == ("call_abc", None) + + def test_compound_id(self): + assert split_tool_call_id("call_abc|fc_1") == ("call_abc", "fc_1") + + def test_compound_empty_item_id(self): + assert split_tool_call_id("call_abc|") == ("call_abc", None) + + def test_none(self): + assert split_tool_call_id(None) == ("call_0", None) + + def test_empty_string(self): + assert split_tool_call_id("") == ("call_0", None) + + def test_non_string(self): + assert split_tool_call_id(42) == ("call_0", None) + + +# ====================================================================== +# converters — convert_user_message +# ====================================================================== + + +class TestConvertUserMessage: + def test_string_content(self): + result = convert_user_message("hello") + assert result == {"role": "user", "content": [{"type": "input_text", "text": "hello"}]} + + def test_text_block(self): + result = convert_user_message([{"type": "text", "text": "hi"}]) + assert result["content"] == [{"type": "input_text", "text": "hi"}] + + def test_image_url_block(self): + result = convert_user_message([ + {"type": "image_url", "image_url": {"url": "https://img.example/a.png"}}, + ]) + assert result["content"] == [ + {"type": "input_image", "image_url": "https://img.example/a.png", "detail": "auto"}, + ] + + def test_mixed_text_and_image(self): + result = convert_user_message([ + {"type": "text", "text": "what's this?"}, + {"type": "image_url", "image_url": {"url": "https://img.example/b.png"}}, + ]) + assert len(result["content"]) == 2 + assert result["content"][0]["type"] == "input_text" + assert result["content"][1]["type"] == "input_image" + + def test_empty_list_falls_back(self): + result = convert_user_message([]) + assert result["content"] == [{"type": "input_text", "text": ""}] + + def test_none_falls_back(self): + result = convert_user_message(None) + assert result["content"] == [{"type": "input_text", "text": ""}] + + def test_image_without_url_skipped(self): + result = convert_user_message([{"type": "image_url", "image_url": {}}]) + assert result["content"] == [{"type": "input_text", "text": ""}] + + def test_meta_fields_not_leaked(self): + """_meta on content blocks must never appear in converted output.""" + result = convert_user_message([ + {"type": "text", "text": "hi", "_meta": {"path": "/tmp/x"}}, + ]) + assert "_meta" not in result["content"][0] + + def test_non_dict_items_skipped(self): + result = convert_user_message(["just a string", 42]) + assert result["content"] == [{"type": "input_text", "text": ""}] + + +# ====================================================================== +# converters — convert_messages +# ====================================================================== + + +class TestConvertMessages: + def test_system_extracted_as_instructions(self): + msgs = [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "Hi"}, + ] + instructions, items = convert_messages(msgs) + assert instructions == "You are helpful." + assert len(items) == 1 + assert items[0]["role"] == "user" + + def test_multiple_system_messages_last_wins(self): + msgs = [ + {"role": "system", "content": "first"}, + {"role": "system", "content": "second"}, + {"role": "user", "content": "x"}, + ] + instructions, _ = convert_messages(msgs) + assert instructions == "second" + + def test_user_message_converted(self): + _, items = convert_messages([{"role": "user", "content": "hello"}]) + assert items[0]["role"] == "user" + assert items[0]["content"][0]["type"] == "input_text" + + def test_assistant_text_message(self): + _, items = convert_messages([ + {"role": "assistant", "content": "I'll help"}, + ]) + assert items[0]["type"] == "message" + assert items[0]["role"] == "assistant" + assert items[0]["content"][0]["type"] == "output_text" + assert items[0]["content"][0]["text"] == "I'll help" + + def test_assistant_empty_content_skipped(self): + _, items = convert_messages([{"role": "assistant", "content": ""}]) + assert len(items) == 0 + + def test_assistant_with_tool_calls(self): + _, items = convert_messages([{ + "role": "assistant", + "content": None, + "tool_calls": [{ + "id": "call_abc|fc_1", + "function": {"name": "get_weather", "arguments": '{"city":"SF"}'}, + }], + }]) + assert items[0]["type"] == "function_call" + assert items[0]["call_id"] == "call_abc" + assert items[0]["id"] == "fc_1" + assert items[0]["name"] == "get_weather" + + def test_assistant_with_tool_calls_no_id(self): + """Fallback IDs when tool_call.id is missing.""" + _, items = convert_messages([{ + "role": "assistant", + "content": None, + "tool_calls": [{"function": {"name": "f1", "arguments": "{}"}}], + }]) + assert items[0]["call_id"] == "call_0" + assert items[0]["id"].startswith("fc_") + + def test_tool_message(self): + _, items = convert_messages([{ + "role": "tool", + "tool_call_id": "call_abc", + "content": "result text", + }]) + assert items[0]["type"] == "function_call_output" + assert items[0]["call_id"] == "call_abc" + assert items[0]["output"] == "result text" + + def test_tool_message_dict_content(self): + _, items = convert_messages([{ + "role": "tool", + "tool_call_id": "call_1", + "content": {"key": "value"}, + }]) + assert items[0]["output"] == '{"key": "value"}' + + def test_non_standard_keys_not_leaked(self): + """Extra keys on messages must not appear in converted items.""" + _, items = convert_messages([{ + "role": "user", + "content": "hi", + "extra_field": "should vanish", + "_meta": {"path": "/tmp"}, + }]) + item = items[0] + assert "extra_field" not in str(item) + assert "_meta" not in str(item) + + def test_full_conversation_roundtrip(self): + """System + user + assistant(tool_call) + tool → correct structure.""" + msgs = [ + {"role": "system", "content": "Be concise."}, + {"role": "user", "content": "Weather in SF?"}, + { + "role": "assistant", "content": None, + "tool_calls": [{ + "id": "c1|fc1", + "function": {"name": "get_weather", "arguments": '{"city":"SF"}'}, + }], + }, + {"role": "tool", "tool_call_id": "c1", "content": '{"temp":72}'}, + ] + instructions, items = convert_messages(msgs) + assert instructions == "Be concise." + assert len(items) == 3 # user, function_call, function_call_output + assert items[0]["role"] == "user" + assert items[1]["type"] == "function_call" + assert items[2]["type"] == "function_call_output" + + +# ====================================================================== +# converters — convert_tools +# ====================================================================== + + +class TestConvertTools: + def test_standard_function_tool(self): + tools = [{"type": "function", "function": { + "name": "get_weather", + "description": "Get weather", + "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}, + }}] + result = convert_tools(tools) + assert len(result) == 1 + assert result[0]["type"] == "function" + assert result[0]["name"] == "get_weather" + assert result[0]["description"] == "Get weather" + assert "properties" in result[0]["parameters"] + + def test_tool_without_name_skipped(self): + tools = [{"type": "function", "function": {"parameters": {}}}] + assert convert_tools(tools) == [] + + def test_tool_without_function_wrapper(self): + """Direct dict without type=function wrapper.""" + tools = [{"name": "f1", "description": "d", "parameters": {}}] + result = convert_tools(tools) + assert result[0]["name"] == "f1" + + def test_missing_optional_fields_default(self): + tools = [{"type": "function", "function": {"name": "f"}}] + result = convert_tools(tools) + assert result[0]["description"] == "" + assert result[0]["parameters"] == {} + + def test_multiple_tools(self): + tools = [ + {"type": "function", "function": {"name": "a", "parameters": {}}}, + {"type": "function", "function": {"name": "b", "parameters": {}}}, + ] + assert len(convert_tools(tools)) == 2 + + +# ====================================================================== +# parsing — map_finish_reason +# ====================================================================== + + +class TestMapFinishReason: + def test_completed(self): + assert map_finish_reason("completed") == "stop" + + def test_incomplete(self): + assert map_finish_reason("incomplete") == "length" + + def test_failed(self): + assert map_finish_reason("failed") == "error" + + def test_cancelled(self): + assert map_finish_reason("cancelled") == "error" + + def test_none_defaults_to_stop(self): + assert map_finish_reason(None) == "stop" + + def test_unknown_defaults_to_stop(self): + assert map_finish_reason("some_new_status") == "stop" + + +# ====================================================================== +# parsing — parse_response_output +# ====================================================================== + + +class TestParseResponseOutput: + def test_text_response(self): + resp = { + "output": [{"type": "message", "role": "assistant", + "content": [{"type": "output_text", "text": "Hello!"}]}], + "status": "completed", + "usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, + } + result = parse_response_output(resp) + assert result.content == "Hello!" + assert result.finish_reason == "stop" + assert result.usage == {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15} + assert result.tool_calls == [] + + def test_tool_call_response(self): + resp = { + "output": [{ + "type": "function_call", + "call_id": "call_1", "id": "fc_1", + "name": "get_weather", + "arguments": '{"city": "SF"}', + }], + "status": "completed", + "usage": {}, + } + result = parse_response_output(resp) + assert result.content is None + assert len(result.tool_calls) == 1 + assert result.tool_calls[0].name == "get_weather" + assert result.tool_calls[0].arguments == {"city": "SF"} + assert result.tool_calls[0].id == "call_1|fc_1" + + def test_malformed_tool_arguments_logged(self, loguru_capture): + """Malformed JSON arguments should log a warning and fallback.""" + resp = { + "output": [{ + "type": "function_call", + "call_id": "c1", "id": "fc1", + "name": "f", "arguments": "{bad json", + }], + "status": "completed", "usage": {}, + } + result = parse_response_output(resp) + assert result.tool_calls[0].arguments == {"raw": "{bad json"} + assert any("Failed to parse tool call arguments" in m for m in loguru_capture) + + def test_reasoning_content_extracted(self): + resp = { + "output": [ + {"type": "reasoning", "summary": [ + {"type": "summary_text", "text": "I think "}, + {"type": "summary_text", "text": "therefore I am."}, + ]}, + {"type": "message", "role": "assistant", + "content": [{"type": "output_text", "text": "42"}]}, + ], + "status": "completed", "usage": {}, + } + result = parse_response_output(resp) + assert result.content == "42" + assert result.reasoning_content == "I think therefore I am." + + def test_empty_output(self): + resp = {"output": [], "status": "completed", "usage": {}} + result = parse_response_output(resp) + assert result.content is None + assert result.tool_calls == [] + + def test_incomplete_status(self): + resp = {"output": [], "status": "incomplete", "usage": {}} + result = parse_response_output(resp) + assert result.finish_reason == "length" + + def test_sdk_model_object(self): + """parse_response_output should handle SDK objects with model_dump().""" + mock = MagicMock() + mock.model_dump.return_value = { + "output": [{"type": "message", "role": "assistant", + "content": [{"type": "output_text", "text": "sdk"}]}], + "status": "completed", + "usage": {"input_tokens": 1, "output_tokens": 2, "total_tokens": 3}, + } + result = parse_response_output(mock) + assert result.content == "sdk" + assert result.usage["prompt_tokens"] == 1 + + def test_usage_maps_responses_api_keys(self): + """Responses API uses input_tokens/output_tokens, not prompt_tokens/completion_tokens.""" + resp = { + "output": [], + "status": "completed", + "usage": {"input_tokens": 100, "output_tokens": 50, "total_tokens": 150}, + } + result = parse_response_output(resp) + assert result.usage["prompt_tokens"] == 100 + assert result.usage["completion_tokens"] == 50 + assert result.usage["total_tokens"] == 150 + + +# ====================================================================== +# parsing — consume_sdk_stream +# ====================================================================== + + +class TestConsumeSdkStream: + @pytest.mark.asyncio + async def test_text_stream(self): + ev1 = MagicMock(type="response.output_text.delta", delta="Hello") + ev2 = MagicMock(type="response.output_text.delta", delta=" world") + resp_obj = MagicMock(status="completed", usage=None, output=[]) + ev3 = MagicMock(type="response.completed", response=resp_obj) + + async def stream(): + for e in [ev1, ev2, ev3]: + yield e + + content, tool_calls, finish_reason, usage, reasoning = await consume_sdk_stream(stream()) + assert content == "Hello world" + assert tool_calls == [] + assert finish_reason == "stop" + + @pytest.mark.asyncio + async def test_on_content_delta_called(self): + ev1 = MagicMock(type="response.output_text.delta", delta="hi") + resp_obj = MagicMock(status="completed", usage=None, output=[]) + ev2 = MagicMock(type="response.completed", response=resp_obj) + deltas = [] + + async def cb(text): + deltas.append(text) + + async def stream(): + for e in [ev1, ev2]: + yield e + + await consume_sdk_stream(stream(), on_content_delta=cb) + assert deltas == ["hi"] + + @pytest.mark.asyncio + async def test_tool_call_stream(self): + item_added = MagicMock(type="function_call", call_id="c1", id="fc1", arguments="") + item_added.name = "get_weather" + ev1 = MagicMock(type="response.output_item.added", item=item_added) + ev2 = MagicMock(type="response.function_call_arguments.delta", call_id="c1", delta='{"ci') + ev3 = MagicMock(type="response.function_call_arguments.done", call_id="c1", arguments='{"city":"SF"}') + item_done = MagicMock(type="function_call", call_id="c1", id="fc1", arguments='{"city":"SF"}') + item_done.name = "get_weather" + ev4 = MagicMock(type="response.output_item.done", item=item_done) + resp_obj = MagicMock(status="completed", usage=None, output=[]) + ev5 = MagicMock(type="response.completed", response=resp_obj) + + async def stream(): + for e in [ev1, ev2, ev3, ev4, ev5]: + yield e + + content, tool_calls, finish_reason, usage, reasoning = await consume_sdk_stream(stream()) + assert content == "" + assert len(tool_calls) == 1 + assert tool_calls[0].name == "get_weather" + assert tool_calls[0].arguments == {"city": "SF"} + + @pytest.mark.asyncio + async def test_usage_extracted(self): + usage_obj = MagicMock(input_tokens=10, output_tokens=5, total_tokens=15) + resp_obj = MagicMock(status="completed", usage=usage_obj, output=[]) + ev = MagicMock(type="response.completed", response=resp_obj) + + async def stream(): + yield ev + + _, _, _, usage, _ = await consume_sdk_stream(stream()) + assert usage == {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15} + + @pytest.mark.asyncio + async def test_reasoning_extracted(self): + summary_item = MagicMock(type="summary_text", text="thinking...") + reasoning_item = MagicMock(type="reasoning", summary=[summary_item]) + resp_obj = MagicMock(status="completed", usage=None, output=[reasoning_item]) + ev = MagicMock(type="response.completed", response=resp_obj) + + async def stream(): + yield ev + + _, _, _, _, reasoning = await consume_sdk_stream(stream()) + assert reasoning == "thinking..." + + @pytest.mark.asyncio + async def test_error_event_raises(self): + ev = MagicMock(type="error") + + async def stream(): + yield ev + + with pytest.raises(RuntimeError, match="Response failed"): + await consume_sdk_stream(stream()) + + @pytest.mark.asyncio + async def test_failed_event_raises(self): + ev = MagicMock(type="response.failed") + + async def stream(): + yield ev + + with pytest.raises(RuntimeError, match="Response failed"): + await consume_sdk_stream(stream()) + + @pytest.mark.asyncio + async def test_malformed_tool_args_logged(self, loguru_capture): + """Malformed JSON in streaming tool args should log a warning.""" + item_added = MagicMock(type="function_call", call_id="c1", id="fc1", arguments="") + item_added.name = "f" + ev1 = MagicMock(type="response.output_item.added", item=item_added) + ev2 = MagicMock(type="response.function_call_arguments.done", call_id="c1", arguments="{bad") + item_done = MagicMock(type="function_call", call_id="c1", id="fc1", arguments="{bad") + item_done.name = "f" + ev3 = MagicMock(type="response.output_item.done", item=item_done) + resp_obj = MagicMock(status="completed", usage=None, output=[]) + ev4 = MagicMock(type="response.completed", response=resp_obj) + + async def stream(): + for e in [ev1, ev2, ev3, ev4]: + yield e + + _, tool_calls, _, _, _ = await consume_sdk_stream(stream()) + assert tool_calls[0].arguments == {"raw": "{bad"} + assert any("Failed to parse tool call arguments" in m for m in loguru_capture) From e206cffd7a59238a9a2bef691b58111e214be2e0 Mon Sep 17 00:00:00 2001 From: Kunal Karmakar Date: Tue, 31 Mar 2026 08:37:41 +0000 Subject: [PATCH 67/91] Add tests and handle json --- .../openai_responses_common/parsing.py | 59 +++++++++++++------ .../providers/test_openai_responses_common.py | 8 +-- 2 files changed, 44 insertions(+), 23 deletions(-) diff --git a/nanobot/providers/openai_responses_common/parsing.py b/nanobot/providers/openai_responses_common/parsing.py index 1e38fdc4..fa1ba13c 100644 --- a/nanobot/providers/openai_responses_common/parsing.py +++ b/nanobot/providers/openai_responses_common/parsing.py @@ -7,6 +7,7 @@ from collections.abc import Awaitable, Callable from typing import Any, AsyncGenerator import httpx +import json_repair from loguru import logger from nanobot.providers.base import LLMResponse, ToolCallRequest @@ -27,24 +28,36 @@ def map_finish_reason(status: str | None) -> str: async def iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any], None]: """Yield parsed JSON events from a Responses API SSE stream.""" buffer: list[str] = [] + + def _flush() -> dict[str, Any] | None: + data_lines = [l[5:].strip() for l in buffer if l.startswith("data:")] + buffer.clear() + if not data_lines: + return None + data = "\n".join(data_lines).strip() + if not data or data == "[DONE]": + return None + try: + return json.loads(data) + except Exception: + logger.warning("Failed to parse SSE event JSON: {}", data[:200]) + return None + async for line in response.aiter_lines(): if line == "": if buffer: - data_lines = [l[5:].strip() for l in buffer if l.startswith("data:")] - buffer = [] - if not data_lines: - continue - data = "\n".join(data_lines).strip() - if not data or data == "[DONE]": - continue - try: - yield json.loads(data) - except Exception: - logger.warning("Failed to parse SSE event JSON: {}", data[:200]) - continue + event = _flush() + if event is not None: + yield event continue buffer.append(line) + # Flush any remaining buffer at EOF (#10) + if buffer: + event = _flush() + if event is not None: + yield event + async def consume_sse( response: httpx.Response, @@ -95,11 +108,13 @@ async def consume_sse( except Exception: logger.warning("Failed to parse tool call arguments for '{}': {}", buf.get("name") or item.get("name"), args_raw[:200]) - args = {"raw": args_raw} + args = json_repair.loads(args_raw) + if not isinstance(args, dict): + args = {"raw": args_raw} tool_calls.append( ToolCallRequest( id=f"{call_id}|{buf.get('id') or item.get('id') or 'fc_0'}", - name=buf.get("name") or item.get("name"), + name=buf.get("name") or item.get("name") or "", arguments=args, ) ) @@ -107,7 +122,8 @@ async def consume_sse( status = (event.get("response") or {}).get("status") finish_reason = map_finish_reason(status) elif event_type in {"error", "response.failed"}: - raise RuntimeError("Response failed") + detail = event.get("error") or event.get("message") or event + raise RuntimeError(f"Response failed: {str(detail)[:500]}") return content, tool_calls, finish_reason @@ -158,7 +174,9 @@ def parse_response_output(response: Any) -> LLMResponse: except Exception: logger.warning("Failed to parse tool call arguments for '{}': {}", item.get("name"), str(args_raw)[:200]) - args = {"raw": args_raw} + args = json_repair.loads(args_raw) if isinstance(args_raw, str) else args_raw + if not isinstance(args, dict): + args = {"raw": args_raw} tool_calls.append(ToolCallRequest( id=f"{call_id}|{item_id}", name=item.get("name") or "", @@ -246,11 +264,13 @@ async def consume_sdk_stream( logger.warning("Failed to parse tool call arguments for '{}': {}", buf.get("name") or getattr(item, "name", None), str(args_raw)[:200]) - args = {"raw": args_raw} + args = json_repair.loads(args_raw) + if not isinstance(args, dict): + args = {"raw": args_raw} tool_calls.append( ToolCallRequest( id=f"{call_id}|{buf.get('id') or getattr(item, 'id', None) or 'fc_0'}", - name=buf.get("name") or getattr(item, "name", None), + name=buf.get("name") or getattr(item, "name", None) or "", arguments=args, ) ) @@ -276,6 +296,7 @@ async def consume_sdk_stream( if text: reasoning_content = (reasoning_content or "") + text elif event_type in {"error", "response.failed"}: - raise RuntimeError("Response failed") + detail = getattr(event, "error", None) or getattr(event, "message", None) or event + raise RuntimeError(f"Response failed: {str(detail)[:500]}") return content, tool_calls, finish_reason, usage, reasoning_content diff --git a/tests/providers/test_openai_responses_common.py b/tests/providers/test_openai_responses_common.py index aa972f08..adddf49e 100644 --- a/tests/providers/test_openai_responses_common.py +++ b/tests/providers/test_openai_responses_common.py @@ -492,22 +492,22 @@ class TestConsumeSdkStream: @pytest.mark.asyncio async def test_error_event_raises(self): - ev = MagicMock(type="error") + ev = MagicMock(type="error", error="rate_limit_exceeded") async def stream(): yield ev - with pytest.raises(RuntimeError, match="Response failed"): + with pytest.raises(RuntimeError, match="Response failed.*rate_limit_exceeded"): await consume_sdk_stream(stream()) @pytest.mark.asyncio async def test_failed_event_raises(self): - ev = MagicMock(type="response.failed") + ev = MagicMock(type="response.failed", error="server_error") async def stream(): yield ev - with pytest.raises(RuntimeError, match="Response failed"): + with pytest.raises(RuntimeError, match="Response failed.*server_error"): await consume_sdk_stream(stream()) @pytest.mark.asyncio From 76226274bfb5ad51ad6c77f8e1ebae0312783e2a Mon Sep 17 00:00:00 2001 From: Kunal Karmakar Date: Tue, 31 Mar 2026 09:15:08 +0000 Subject: [PATCH 68/91] Failing test --- tests/providers/test_openai_responses_common.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/providers/test_openai_responses_common.py b/tests/providers/test_openai_responses_common.py index adddf49e..0879685b 100644 --- a/tests/providers/test_openai_responses_common.py +++ b/tests/providers/test_openai_responses_common.py @@ -23,11 +23,7 @@ from nanobot.providers.openai_responses_common.parsing import ( def loguru_capture(): """Capture loguru messages into a list for assertion.""" messages: list[str] = [] - - def sink(message): - messages.append(str(message)) - - handler_id = logger.add(sink, format="{message}", level="DEBUG") + handler_id = logger.add(lambda m: messages.append(str(m)), format="{message}", level="DEBUG") yield messages logger.remove(handler_id) From 61d7411238131155b545d283d510ab3c1b8650e9 Mon Sep 17 00:00:00 2001 From: Kunal Karmakar Date: Tue, 31 Mar 2026 09:22:50 +0000 Subject: [PATCH 69/91] Fix failing test --- .../providers/test_openai_responses_common.py | 28 ++++++++----------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/tests/providers/test_openai_responses_common.py b/tests/providers/test_openai_responses_common.py index 0879685b..15d24041 100644 --- a/tests/providers/test_openai_responses_common.py +++ b/tests/providers/test_openai_responses_common.py @@ -1,9 +1,8 @@ """Tests for the shared openai_responses_common converters and parsers.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest -from loguru import logger from nanobot.providers.base import LLMResponse, ToolCallRequest from nanobot.providers.openai_responses_common.converters import ( @@ -19,15 +18,6 @@ from nanobot.providers.openai_responses_common.parsing import ( ) -@pytest.fixture() -def loguru_capture(): - """Capture loguru messages into a list for assertion.""" - messages: list[str] = [] - handler_id = logger.add(lambda m: messages.append(str(m)), format="{message}", level="DEBUG") - yield messages - logger.remove(handler_id) - - # ====================================================================== # converters — split_tool_call_id # ====================================================================== @@ -332,7 +322,7 @@ class TestParseResponseOutput: assert result.tool_calls[0].arguments == {"city": "SF"} assert result.tool_calls[0].id == "call_1|fc_1" - def test_malformed_tool_arguments_logged(self, loguru_capture): + def test_malformed_tool_arguments_logged(self): """Malformed JSON arguments should log a warning and fallback.""" resp = { "output": [{ @@ -342,9 +332,11 @@ class TestParseResponseOutput: }], "status": "completed", "usage": {}, } - result = parse_response_output(resp) + with patch("nanobot.providers.openai_responses_common.parsing.logger") as mock_logger: + result = parse_response_output(resp) assert result.tool_calls[0].arguments == {"raw": "{bad json"} - assert any("Failed to parse tool call arguments" in m for m in loguru_capture) + mock_logger.warning.assert_called_once() + assert "Failed to parse tool call arguments" in str(mock_logger.warning.call_args) def test_reasoning_content_extracted(self): resp = { @@ -507,7 +499,7 @@ class TestConsumeSdkStream: await consume_sdk_stream(stream()) @pytest.mark.asyncio - async def test_malformed_tool_args_logged(self, loguru_capture): + async def test_malformed_tool_args_logged(self): """Malformed JSON in streaming tool args should log a warning.""" item_added = MagicMock(type="function_call", call_id="c1", id="fc1", arguments="") item_added.name = "f" @@ -523,6 +515,8 @@ class TestConsumeSdkStream: for e in [ev1, ev2, ev3, ev4]: yield e - _, tool_calls, _, _, _ = await consume_sdk_stream(stream()) + with patch("nanobot.providers.openai_responses_common.parsing.logger") as mock_logger: + _, tool_calls, _, _, _ = await consume_sdk_stream(stream()) assert tool_calls[0].arguments == {"raw": "{bad"} - assert any("Failed to parse tool call arguments" in m for m in loguru_capture) + mock_logger.warning.assert_called_once() + assert "Failed to parse tool call arguments" in str(mock_logger.warning.call_args) From ded0967c1804be6da4a7eeedd127c1ba7a2f371b Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Thu, 2 Apr 2026 05:11:56 +0000 Subject: [PATCH 70/91] fix(providers): sanitize azure responses input messages --- nanobot/providers/azure_openai_provider.py | 2 +- tests/providers/test_azure_openai_provider.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/nanobot/providers/azure_openai_provider.py b/nanobot/providers/azure_openai_provider.py index f2f63a5b..bf6ccae8 100644 --- a/nanobot/providers/azure_openai_provider.py +++ b/nanobot/providers/azure_openai_provider.py @@ -87,7 +87,7 @@ class AzureOpenAIProvider(LLMProvider): ) -> dict[str, Any]: """Build the Responses API request body from Chat-Completions-style args.""" deployment = model or self.default_model - instructions, input_items = convert_messages(messages) + instructions, input_items = convert_messages(self._sanitize_empty_content(messages)) body: dict[str, Any] = { "model": deployment, diff --git a/tests/providers/test_azure_openai_provider.py b/tests/providers/test_azure_openai_provider.py index 4a18f3bf..89cea64f 100644 --- a/tests/providers/test_azure_openai_provider.py +++ b/tests/providers/test_azure_openai_provider.py @@ -150,6 +150,19 @@ def test_build_body_image_conversion(): assert image_block["image_url"] == "https://example.com/img.png" +def test_build_body_sanitizes_single_dict_content_block(): + """Single content dicts should be preserved via shared message sanitization.""" + provider = AzureOpenAIProvider(api_key="k", api_base="https://r.com", default_model="gpt-4o") + messages = [{ + "role": "user", + "content": {"type": "text", "text": "Hi from dict content"}, + }] + + body = provider._build_body(messages, None, None, 4096, 0.7, None, None) + + assert body["input"][0]["content"] == [{"type": "input_text", "text": "Hi from dict content"}] + + # --------------------------------------------------------------------------- # chat() — non-streaming # --------------------------------------------------------------------------- From cc33057985b265d6af99167758a5265575dc5f3f Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Thu, 2 Apr 2026 05:38:19 +0000 Subject: [PATCH 71/91] refactor(providers): rename openai responses helpers --- nanobot/providers/azure_openai_provider.py | 6 +-- nanobot/providers/openai_codex_provider.py | 2 +- .../__init__.py | 4 +- .../converters.py | 4 +- .../parsing.py | 39 ++++++++----------- ...ses_common.py => test_openai_responses.py} | 26 ++++++------- 6 files changed, 38 insertions(+), 43 deletions(-) rename nanobot/providers/{openai_responses_common => openai_responses}/__init__.py (80%) rename nanobot/providers/{openai_responses_common => openai_responses}/converters.py (97%) rename nanobot/providers/{openai_responses_common => openai_responses}/parsing.py (91%) rename tests/providers/{test_openai_responses_common.py => test_openai_responses.py} (96%) diff --git a/nanobot/providers/azure_openai_provider.py b/nanobot/providers/azure_openai_provider.py index bf6ccae8..12c74be0 100644 --- a/nanobot/providers/azure_openai_provider.py +++ b/nanobot/providers/azure_openai_provider.py @@ -2,7 +2,7 @@ Uses ``AsyncOpenAI`` pointed at ``https://{endpoint}/openai/v1/`` which routes to the Responses API (``/responses``). Reuses shared conversion -helpers from :mod:`nanobot.providers.openai_responses_common`. +helpers from :mod:`nanobot.providers.openai_responses`. """ from __future__ import annotations @@ -14,7 +14,7 @@ from typing import Any from openai import AsyncOpenAI from nanobot.providers.base import LLMProvider, LLMResponse -from nanobot.providers.openai_responses_common import ( +from nanobot.providers.openai_responses import ( consume_sdk_stream, convert_messages, convert_tools, @@ -30,7 +30,7 @@ class AzureOpenAIProvider(LLMProvider): ``base_url = {endpoint}/openai/v1/`` - Calls ``client.responses.create()`` (Responses API) - Reuses shared message/tool/SSE conversion from - ``openai_responses_common`` + ``openai_responses`` """ def __init__( diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py index 68145173..265b4b10 100644 --- a/nanobot/providers/openai_codex_provider.py +++ b/nanobot/providers/openai_codex_provider.py @@ -13,7 +13,7 @@ from loguru import logger from oauth_cli_kit import get_token as get_codex_token from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest -from nanobot.providers.openai_responses_common import ( +from nanobot.providers.openai_responses import ( consume_sse, convert_messages, convert_tools, diff --git a/nanobot/providers/openai_responses_common/__init__.py b/nanobot/providers/openai_responses/__init__.py similarity index 80% rename from nanobot/providers/openai_responses_common/__init__.py rename to nanobot/providers/openai_responses/__init__.py index 80a03e43..b40e896e 100644 --- a/nanobot/providers/openai_responses_common/__init__.py +++ b/nanobot/providers/openai_responses/__init__.py @@ -1,12 +1,12 @@ """Shared helpers for OpenAI Responses API providers (Codex, Azure OpenAI).""" -from nanobot.providers.openai_responses_common.converters import ( +from nanobot.providers.openai_responses.converters import ( convert_messages, convert_tools, convert_user_message, split_tool_call_id, ) -from nanobot.providers.openai_responses_common.parsing import ( +from nanobot.providers.openai_responses.parsing import ( FINISH_REASON_MAP, consume_sdk_stream, consume_sse, diff --git a/nanobot/providers/openai_responses_common/converters.py b/nanobot/providers/openai_responses/converters.py similarity index 97% rename from nanobot/providers/openai_responses_common/converters.py rename to nanobot/providers/openai_responses/converters.py index 37596692..e0bfe832 100644 --- a/nanobot/providers/openai_responses_common/converters.py +++ b/nanobot/providers/openai_responses/converters.py @@ -58,8 +58,8 @@ def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str def convert_user_message(content: Any) -> dict[str, Any]: """Convert a user message's content to Responses API format. - Handles plain strings, ``text`` blocks → ``input_text``, and - ``image_url`` blocks → ``input_image``. + Handles plain strings, ``text`` blocks -> ``input_text``, and + ``image_url`` blocks -> ``input_image``. """ if isinstance(content, str): return {"role": "user", "content": [{"type": "input_text", "text": content}]} diff --git a/nanobot/providers/openai_responses_common/parsing.py b/nanobot/providers/openai_responses/parsing.py similarity index 91% rename from nanobot/providers/openai_responses_common/parsing.py rename to nanobot/providers/openai_responses/parsing.py index fa1ba13c..9e3f0ef0 100644 --- a/nanobot/providers/openai_responses_common/parsing.py +++ b/nanobot/providers/openai_responses/parsing.py @@ -106,8 +106,11 @@ async def consume_sse( try: args = json.loads(args_raw) except Exception: - logger.warning("Failed to parse tool call arguments for '{}': {}", - buf.get("name") or item.get("name"), args_raw[:200]) + logger.warning( + "Failed to parse tool call arguments for '{}': {}", + buf.get("name") or item.get("name"), + args_raw[:200], + ) args = json_repair.loads(args_raw) if not isinstance(args, dict): args = {"raw": args_raw} @@ -129,12 +132,7 @@ async def consume_sse( def parse_response_output(response: Any) -> LLMResponse: - """Parse an SDK ``Response`` object (from ``client.responses.create()``) - into an ``LLMResponse``. - - Works with both Pydantic model objects and plain dicts. - """ - # Normalise to dict + """Parse an SDK ``Response`` object into an ``LLMResponse``.""" if not isinstance(response, dict): dump = getattr(response, "model_dump", None) response = dump() if callable(dump) else vars(response) @@ -158,7 +156,6 @@ def parse_response_output(response: Any) -> LLMResponse: if block.get("type") == "output_text": content_parts.append(block.get("text") or "") elif item_type == "reasoning": - # Reasoning items may have a summary list with text blocks for s in item.get("summary") or []: if not isinstance(s, dict): dump = getattr(s, "model_dump", None) @@ -172,8 +169,11 @@ def parse_response_output(response: Any) -> LLMResponse: try: args = json.loads(args_raw) if isinstance(args_raw, str) else args_raw except Exception: - logger.warning("Failed to parse tool call arguments for '{}': {}", - item.get("name"), str(args_raw)[:200]) + logger.warning( + "Failed to parse tool call arguments for '{}': {}", + item.get("name"), + str(args_raw)[:200], + ) args = json_repair.loads(args_raw) if isinstance(args_raw, str) else args_raw if not isinstance(args, dict): args = {"raw": args_raw} @@ -211,12 +211,7 @@ async def consume_sdk_stream( stream: Any, on_content_delta: Callable[[str], Awaitable[None]] | None = None, ) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]: - """Consume an SDK async stream from ``client.responses.create(stream=True)``. - - The SDK yields typed event objects with a ``.type`` attribute and - event-specific fields. Returns - ``(content, tool_calls, finish_reason, usage, reasoning_content)``. - """ + """Consume an SDK async stream from ``client.responses.create(stream=True)``.""" content = "" tool_calls: list[ToolCallRequest] = [] tool_call_buffers: dict[str, dict[str, Any]] = {} @@ -261,9 +256,11 @@ async def consume_sdk_stream( try: args = json.loads(args_raw) except Exception: - logger.warning("Failed to parse tool call arguments for '{}': {}", - buf.get("name") or getattr(item, "name", None), - str(args_raw)[:200]) + logger.warning( + "Failed to parse tool call arguments for '{}': {}", + buf.get("name") or getattr(item, "name", None), + str(args_raw)[:200], + ) args = json_repair.loads(args_raw) if not isinstance(args, dict): args = {"raw": args_raw} @@ -278,7 +275,6 @@ async def consume_sdk_stream( resp = getattr(event, "response", None) status = getattr(resp, "status", None) if resp else None finish_reason = map_finish_reason(status) - # Extract usage from the completed response if resp: usage_obj = getattr(resp, "usage", None) if usage_obj: @@ -287,7 +283,6 @@ async def consume_sdk_stream( "completion_tokens": int(getattr(usage_obj, "output_tokens", 0) or 0), "total_tokens": int(getattr(usage_obj, "total_tokens", 0) or 0), } - # Extract reasoning_content from completed output items for out_item in getattr(resp, "output", None) or []: if getattr(out_item, "type", None) == "reasoning": for s in getattr(out_item, "summary", None) or []: diff --git a/tests/providers/test_openai_responses_common.py b/tests/providers/test_openai_responses.py similarity index 96% rename from tests/providers/test_openai_responses_common.py rename to tests/providers/test_openai_responses.py index 15d24041..ce422065 100644 --- a/tests/providers/test_openai_responses_common.py +++ b/tests/providers/test_openai_responses.py @@ -1,17 +1,17 @@ -"""Tests for the shared openai_responses_common converters and parsers.""" +"""Tests for the shared openai_responses converters and parsers.""" from unittest.mock import MagicMock, patch import pytest from nanobot.providers.base import LLMResponse, ToolCallRequest -from nanobot.providers.openai_responses_common.converters import ( +from nanobot.providers.openai_responses.converters import ( convert_messages, convert_tools, convert_user_message, split_tool_call_id, ) -from nanobot.providers.openai_responses_common.parsing import ( +from nanobot.providers.openai_responses.parsing import ( consume_sdk_stream, map_finish_reason, parse_response_output, @@ -19,7 +19,7 @@ from nanobot.providers.openai_responses_common.parsing import ( # ====================================================================== -# converters — split_tool_call_id +# converters - split_tool_call_id # ====================================================================== @@ -44,7 +44,7 @@ class TestSplitToolCallId: # ====================================================================== -# converters — convert_user_message +# converters - convert_user_message # ====================================================================== @@ -99,7 +99,7 @@ class TestConvertUserMessage: # ====================================================================== -# converters — convert_messages +# converters - convert_messages # ====================================================================== @@ -196,7 +196,7 @@ class TestConvertMessages: assert "_meta" not in str(item) def test_full_conversation_roundtrip(self): - """System + user + assistant(tool_call) + tool → correct structure.""" + """System + user + assistant(tool_call) + tool -> correct structure.""" msgs = [ {"role": "system", "content": "Be concise."}, {"role": "user", "content": "Weather in SF?"}, @@ -218,7 +218,7 @@ class TestConvertMessages: # ====================================================================== -# converters — convert_tools +# converters - convert_tools # ====================================================================== @@ -261,7 +261,7 @@ class TestConvertTools: # ====================================================================== -# parsing — map_finish_reason +# parsing - map_finish_reason # ====================================================================== @@ -286,7 +286,7 @@ class TestMapFinishReason: # ====================================================================== -# parsing — parse_response_output +# parsing - parse_response_output # ====================================================================== @@ -332,7 +332,7 @@ class TestParseResponseOutput: }], "status": "completed", "usage": {}, } - with patch("nanobot.providers.openai_responses_common.parsing.logger") as mock_logger: + with patch("nanobot.providers.openai_responses.parsing.logger") as mock_logger: result = parse_response_output(resp) assert result.tool_calls[0].arguments == {"raw": "{bad json"} mock_logger.warning.assert_called_once() @@ -392,7 +392,7 @@ class TestParseResponseOutput: # ====================================================================== -# parsing — consume_sdk_stream +# parsing - consume_sdk_stream # ====================================================================== @@ -515,7 +515,7 @@ class TestConsumeSdkStream: for e in [ev1, ev2, ev3, ev4]: yield e - with patch("nanobot.providers.openai_responses_common.parsing.logger") as mock_logger: + with patch("nanobot.providers.openai_responses.parsing.logger") as mock_logger: _, tool_calls, _, _, _ = await consume_sdk_stream(stream()) assert tool_calls[0].arguments == {"raw": "{bad"} mock_logger.warning.assert_called_once() From 7a6416bcb21a61659dcd6670924fcc0c7e80d4b3 Mon Sep 17 00:00:00 2001 From: haosenwang1018 Date: Thu, 2 Apr 2026 06:26:10 +0000 Subject: [PATCH 72/91] test(matrix): skip cleanly when optional deps are missing --- tests/channels/test_matrix_channel.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/channels/test_matrix_channel.py b/tests/channels/test_matrix_channel.py index 18a8e109..27b7e125 100644 --- a/tests/channels/test_matrix_channel.py +++ b/tests/channels/test_matrix_channel.py @@ -3,16 +3,14 @@ from pathlib import Path from types import SimpleNamespace import pytest + +pytest.importorskip("nio") +pytest.importorskip("nh3") +pytest.importorskip("mistune") from nio import RoomSendResponse from nanobot.channels.matrix import _build_matrix_text_content -# Check optional matrix dependencies before importing -try: - import nh3 # noqa: F401 -except ImportError: - pytest.skip("Matrix dependencies not installed (nh3)", allow_module_level=True) - import nanobot.channels.matrix as matrix_module from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus From 7332d133a772e826a753d6c2df823e405ca4fabf Mon Sep 17 00:00:00 2001 From: masterlyj <167326996+masterlyj@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:49:08 +0800 Subject: [PATCH 73/91] feat(cli): add --config option to channels login and status commands Allows users to specify custom config file paths when managing channels. Usage: nanobot channels login weixin --config .nanobot-feishu/config.json nanobot channels status -c .nanobot-qq/config.json - Added optional --config/-c parameter to both commands - Defaults to ~/.nanobot/config.json when not specified - Maintains backward compatibility --- nanobot/cli/commands.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 49521aa1..b1a15ebf 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -1023,12 +1023,14 @@ app.add_typer(channels_app, name="channels") @channels_app.command("status") -def channels_status(): +def channels_status( + config_path: str | None = typer.Option(None, "--config", "-c", help="Path to config file"), +): """Show channel status.""" from nanobot.channels.registry import discover_all from nanobot.config.loader import load_config - config = load_config() + config = load_config(Path(config_path) if config_path else None) table = Table(title="Channel Status") table.add_column("Channel", style="cyan") @@ -1115,12 +1117,13 @@ def _get_bridge_dir() -> Path: def channels_login( channel_name: str = typer.Argument(..., help="Channel name (e.g. weixin, whatsapp)"), force: bool = typer.Option(False, "--force", "-f", help="Force re-authentication even if already logged in"), + config_path: str | None = typer.Option(None, "--config", "-c", help="Path to config file"), ): """Authenticate with a channel via QR code or other interactive login.""" from nanobot.channels.registry import discover_all from nanobot.config.loader import load_config - config = load_config() + config = load_config(Path(config_path) if config_path else None) channel_cfg = getattr(config.channels, channel_name, None) or {} # Validate channel exists From 11ba733ab6d3c8abe79ca72f22e44b23c0d094a7 Mon Sep 17 00:00:00 2001 From: masterlyj <167326996+masterlyj@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:09:48 +0800 Subject: [PATCH 74/91] fix(test): update load_config mock to accept config_path parameter --- tests/channels/test_channel_plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/channels/test_channel_plugins.py b/tests/channels/test_channel_plugins.py index a0b458a0..93bf7f1d 100644 --- a/tests/channels/test_channel_plugins.py +++ b/tests/channels/test_channel_plugins.py @@ -208,7 +208,7 @@ def test_channels_login_uses_discovered_plugin_class(monkeypatch): seen["config"] = self.config return True - monkeypatch.setattr("nanobot.config.loader.load_config", lambda: Config()) + monkeypatch.setattr("nanobot.config.loader.load_config", lambda config_path=None: Config()) monkeypatch.setattr( "nanobot.channels.registry.discover_all", lambda: {"fakeplugin": _LoginPlugin}, From 3558fe4933e8b89a27cfda3b1ff04d30f731de5c Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Thu, 2 Apr 2026 10:31:50 +0000 Subject: [PATCH 75/91] fix(cli): honor custom config path in channel commands --- nanobot/cli/commands.py | 16 ++++++-- tests/channels/test_channel_plugins.py | 51 ++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index b1a15ebf..53d17dfa 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -1028,9 +1028,13 @@ def channels_status( ): """Show channel status.""" from nanobot.channels.registry import discover_all - from nanobot.config.loader import load_config + from nanobot.config.loader import load_config, set_config_path - config = load_config(Path(config_path) if config_path else None) + resolved_config_path = Path(config_path).expanduser().resolve() if config_path else None + if resolved_config_path is not None: + set_config_path(resolved_config_path) + + config = load_config(resolved_config_path) table = Table(title="Channel Status") table.add_column("Channel", style="cyan") @@ -1121,9 +1125,13 @@ def channels_login( ): """Authenticate with a channel via QR code or other interactive login.""" from nanobot.channels.registry import discover_all - from nanobot.config.loader import load_config + from nanobot.config.loader import load_config, set_config_path - config = load_config(Path(config_path) if config_path else None) + resolved_config_path = Path(config_path).expanduser().resolve() if config_path else None + if resolved_config_path is not None: + set_config_path(resolved_config_path) + + config = load_config(resolved_config_path) channel_cfg = getattr(config.channels, channel_name, None) or {} # Validate channel exists diff --git a/tests/channels/test_channel_plugins.py b/tests/channels/test_channel_plugins.py index 93bf7f1d..4cf4fab2 100644 --- a/tests/channels/test_channel_plugins.py +++ b/tests/channels/test_channel_plugins.py @@ -220,6 +220,57 @@ def test_channels_login_uses_discovered_plugin_class(monkeypatch): assert seen["force"] is True +def test_channels_login_sets_custom_config_path(monkeypatch, tmp_path): + from nanobot.cli.commands import app + from nanobot.config.schema import Config + from typer.testing import CliRunner + + runner = CliRunner() + seen: dict[str, object] = {} + config_path = tmp_path / "custom-config.json" + + class _LoginPlugin(_FakePlugin): + async def login(self, force: bool = False) -> bool: + return True + + monkeypatch.setattr("nanobot.config.loader.load_config", lambda config_path=None: Config()) + monkeypatch.setattr( + "nanobot.config.loader.set_config_path", + lambda path: seen.__setitem__("config_path", path), + ) + monkeypatch.setattr( + "nanobot.channels.registry.discover_all", + lambda: {"fakeplugin": _LoginPlugin}, + ) + + result = runner.invoke(app, ["channels", "login", "fakeplugin", "--config", str(config_path)]) + + assert result.exit_code == 0 + assert seen["config_path"] == config_path.resolve() + + +def test_channels_status_sets_custom_config_path(monkeypatch, tmp_path): + from nanobot.cli.commands import app + from nanobot.config.schema import Config + from typer.testing import CliRunner + + runner = CliRunner() + seen: dict[str, object] = {} + config_path = tmp_path / "custom-config.json" + + monkeypatch.setattr("nanobot.config.loader.load_config", lambda config_path=None: Config()) + monkeypatch.setattr( + "nanobot.config.loader.set_config_path", + lambda path: seen.__setitem__("config_path", path), + ) + monkeypatch.setattr("nanobot.channels.registry.discover_all", lambda: {}) + + result = runner.invoke(app, ["channels", "status", "--config", str(config_path)]) + + assert result.exit_code == 0 + assert seen["config_path"] == config_path.resolve() + + @pytest.mark.asyncio async def test_manager_skips_disabled_plugin(): fake_config = SimpleNamespace( From 714a4c7bb6574df5639cfe9de2aab0e4473aeed0 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Thu, 2 Apr 2026 10:57:12 +0000 Subject: [PATCH 76/91] fix(runtime): address review feedback on retry and cleanup --- nanobot/providers/base.py | 17 ++++++ nanobot/utils/helpers.py | 5 +- tests/agent/test_runner.py | 77 ++++++++++++++++++++++++++ tests/providers/test_provider_retry.py | 24 ++++++++ 4 files changed, 121 insertions(+), 2 deletions(-) diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index c51f5dda..852e9c97 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -72,6 +72,7 @@ class LLMProvider(ABC): _CHAT_RETRY_DELAYS = (1, 2, 4) _PERSISTENT_MAX_DELAY = 60 + _PERSISTENT_IDENTICAL_ERROR_LIMIT = 10 _RETRY_HEARTBEAT_CHUNK = 30 _TRANSIENT_ERROR_MARKERS = ( "429", @@ -377,12 +378,20 @@ class LLMProvider(ABC): delays = list(self._CHAT_RETRY_DELAYS) persistent = retry_mode == "persistent" last_response: LLMResponse | None = None + last_error_key: str | None = None + identical_error_count = 0 while True: attempt += 1 response = await call(**kw) if response.finish_reason != "error": return response last_response = response + error_key = ((response.content or "").strip().lower() or None) + if error_key and error_key == last_error_key: + identical_error_count += 1 + else: + last_error_key = error_key + identical_error_count = 1 if error_key else 0 if not self._is_transient_error(response.content): stripped = self._strip_image_content(original_messages) @@ -395,6 +404,14 @@ class LLMProvider(ABC): return await call(**retry_kw) return response + if persistent and identical_error_count >= self._PERSISTENT_IDENTICAL_ERROR_LIMIT: + logger.warning( + "Stopping persistent retry after {} identical transient errors: {}", + identical_error_count, + (response.content or "")[:120].lower(), + ) + return response + if not persistent and attempt > len(delays): break diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index cca2992e..fa3e423b 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -11,6 +11,7 @@ from pathlib import Path from typing import Any import tiktoken +from loguru import logger def strip_think(text: str) -> str: @@ -214,8 +215,8 @@ def maybe_persist_tool_result( bucket = ensure_dir(root / safe_filename(session_key or "default")) try: _cleanup_tool_result_buckets(root, bucket) - except Exception: - pass + except Exception as exc: + logger.warning("Failed to clean stale tool result buckets in {}: {}", root, exc) path = bucket / f"{safe_filename(tool_call_id)}.{suffix}" if not path.exists(): if suffix == "json" and isinstance(content, list): diff --git a/tests/agent/test_runner.py b/tests/agent/test_runner.py index b98550a6..9009480e 100644 --- a/tests/agent/test_runner.py +++ b/tests/agent/test_runner.py @@ -359,6 +359,32 @@ def test_persist_tool_result_leaves_no_temp_files(tmp_path): assert list((root / "current_session").glob("*.tmp")) == [] +def test_persist_tool_result_logs_cleanup_failures(monkeypatch, tmp_path): + from nanobot.utils.helpers import maybe_persist_tool_result + + warnings: list[str] = [] + + monkeypatch.setattr( + "nanobot.utils.helpers._cleanup_tool_result_buckets", + lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("busy")), + ) + monkeypatch.setattr( + "nanobot.utils.helpers.logger.warning", + lambda message, *args: warnings.append(message.format(*args)), + ) + + persisted = maybe_persist_tool_result( + tmp_path, + "current:session", + "call_big", + "x" * 5000, + max_chars=64, + ) + + assert "[tool output persisted]" in persisted + assert warnings and "Failed to clean stale tool result buckets" in warnings[0] + + @pytest.mark.asyncio async def test_runner_uses_raw_messages_when_context_governance_fails(): from nanobot.agent.runner import AgentRunSpec, AgentRunner @@ -392,6 +418,55 @@ async def test_runner_uses_raw_messages_when_context_governance_fails(): assert captured_messages == initial_messages +def test_snip_history_drops_orphaned_tool_results_from_trimmed_slice(monkeypatch): + from nanobot.agent.runner import AgentRunSpec, AgentRunner + + provider = MagicMock() + tools = MagicMock() + tools.get_definitions.return_value = [] + runner = AgentRunner(provider) + messages = [ + {"role": "system", "content": "system"}, + {"role": "user", "content": "old user"}, + { + "role": "assistant", + "content": "tool call", + "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "ls", "arguments": "{}"}}], + }, + {"role": "tool", "tool_call_id": "call_1", "content": "tool output"}, + {"role": "assistant", "content": "after tool"}, + ] + spec = AgentRunSpec( + initial_messages=messages, + tools=tools, + model="test-model", + max_iterations=1, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + context_window_tokens=2000, + context_block_limit=100, + ) + + monkeypatch.setattr("nanobot.agent.runner.estimate_prompt_tokens_chain", lambda *_args, **_kwargs: (500, None)) + token_sizes = { + "old user": 120, + "tool call": 120, + "tool output": 40, + "after tool": 40, + "system": 0, + } + monkeypatch.setattr( + "nanobot.agent.runner.estimate_message_tokens", + lambda msg: token_sizes.get(str(msg.get("content")), 40), + ) + + trimmed = runner._snip_history(spec, messages) + + assert trimmed == [ + {"role": "system", "content": "system"}, + {"role": "assistant", "content": "after tool"}, + ] + + @pytest.mark.asyncio async def test_runner_keeps_going_when_tool_result_persistence_fails(): from nanobot.agent.runner import AgentRunSpec, AgentRunner @@ -614,6 +689,7 @@ async def test_runner_accumulates_usage_and_preserves_cached_tokens(): tools=tools, model="test-model", max_iterations=3, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, )) # Usage should be accumulated across iterations @@ -652,6 +728,7 @@ async def test_runner_passes_cached_tokens_to_hook_context(): tools=tools, model="test-model", max_iterations=1, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, hook=UsageHook(), )) diff --git a/tests/providers/test_provider_retry.py b/tests/providers/test_provider_retry.py index 6b5c8d8d..1d8facf5 100644 --- a/tests/providers/test_provider_retry.py +++ b/tests/providers/test_provider_retry.py @@ -240,3 +240,27 @@ async def test_chat_with_retry_uses_retry_after_and_emits_wait_progress(monkeypa assert progress and "7s" in progress[0] +@pytest.mark.asyncio +async def test_persistent_retry_aborts_after_ten_identical_transient_errors(monkeypatch) -> None: + provider = ScriptedProvider([ + *[LLMResponse(content="429 rate limit", finish_reason="error") for _ in range(10)], + LLMResponse(content="ok"), + ]) + delays: list[float] = [] + + async def _fake_sleep(delay: float) -> None: + delays.append(delay) + + monkeypatch.setattr("nanobot.providers.base.asyncio.sleep", _fake_sleep) + + response = await provider.chat_with_retry( + messages=[{"role": "user", "content": "hello"}], + retry_mode="persistent", + ) + + assert response.finish_reason == "error" + assert response.content == "429 rate limit" + assert provider.calls == 10 + assert delays == [1, 2, 4, 4, 4, 4, 4, 4, 4] + + From e4b335ce8197f209e640927194cf13c6b5266f57 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Thu, 2 Apr 2026 13:54:40 +0000 Subject: [PATCH 77/91] refactor: extract runtime response guards into utils runtime module --- nanobot/agent/loop.py | 5 +- nanobot/agent/runner.py | 187 +++++++++++++++++++++++++++------- nanobot/api/server.py | 4 +- nanobot/utils/helpers.py | 4 +- nanobot/utils/runtime.py | 88 ++++++++++++++++ tests/agent/test_runner.py | 201 +++++++++++++++++++++++++++++++++++++ tests/test_openai_api.py | 4 +- 7 files changed, 449 insertions(+), 44 deletions(-) create mode 100644 nanobot/utils/runtime.py diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 2e5b0409..4a68a19f 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -33,6 +33,7 @@ from nanobot.config.schema import AgentDefaults from nanobot.providers.base import LLMProvider from nanobot.session.manager import Session, SessionManager from nanobot.utils.helpers import image_placeholder_text, truncate_text +from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE if TYPE_CHECKING: from nanobot.config.schema import ChannelsConfig, ExecToolConfig, WebSearchConfig @@ -588,8 +589,8 @@ class AgentLoop: message_id=msg.metadata.get("message_id"), ) - if final_content is None: - final_content = "I've completed processing but have no response to give." + if final_content is None or not final_content.strip(): + final_content = EMPTY_FINAL_RESPONSE_MESSAGE self._save_turn(session, all_msgs, 1 + len(history)) self._clear_runtime_checkpoint(session) diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index 90b286c0..a8676a8e 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -20,6 +20,13 @@ from nanobot.utils.helpers import ( maybe_persist_tool_result, truncate_text, ) +from nanobot.utils.runtime import ( + EMPTY_FINAL_RESPONSE_MESSAGE, + build_finalization_retry_message, + ensure_nonempty_tool_result, + is_blank_text, + repeated_external_lookup_error, +) _DEFAULT_MAX_ITERATIONS_MESSAGE = ( "I reached the maximum number of tool call iterations ({max_iterations}) " @@ -77,10 +84,11 @@ class AgentRunner: messages = list(spec.initial_messages) final_content: str | None = None tools_used: list[str] = [] - usage: dict[str, int] = {} + usage: dict[str, int] = {"prompt_tokens": 0, "completion_tokens": 0} error: str | None = None stop_reason = "completed" tool_events: list[dict[str, str]] = [] + external_lookup_counts: dict[str, int] = {} for iteration in range(spec.max_iterations): try: @@ -96,41 +104,12 @@ class AgentRunner: messages_for_model = messages context = AgentHookContext(iteration=iteration, messages=messages) await hook.before_iteration(context) - kwargs: dict[str, Any] = { - "messages": messages_for_model, - "tools": spec.tools.get_definitions(), - "model": spec.model, - "retry_mode": spec.provider_retry_mode, - "on_retry_wait": spec.progress_callback, - } - if spec.temperature is not None: - kwargs["temperature"] = spec.temperature - if spec.max_tokens is not None: - kwargs["max_tokens"] = spec.max_tokens - if spec.reasoning_effort is not None: - kwargs["reasoning_effort"] = spec.reasoning_effort - - if hook.wants_streaming(): - async def _stream(delta: str) -> None: - await hook.on_stream(context, delta) - - response = await self.provider.chat_stream_with_retry( - **kwargs, - on_content_delta=_stream, - ) - else: - response = await self.provider.chat_with_retry(**kwargs) - - raw_usage = response.usage or {} + response = await self._request_model(spec, messages_for_model, hook, context) + raw_usage = self._usage_dict(response.usage) context.response = response - context.usage = raw_usage + context.usage = dict(raw_usage) context.tool_calls = list(response.tool_calls) - # Accumulate standard fields into result usage. - usage["prompt_tokens"] = usage.get("prompt_tokens", 0) + int(raw_usage.get("prompt_tokens", 0) or 0) - usage["completion_tokens"] = usage.get("completion_tokens", 0) + int(raw_usage.get("completion_tokens", 0) or 0) - cached = raw_usage.get("cached_tokens") - if cached: - usage["cached_tokens"] = usage.get("cached_tokens", 0) + int(cached) + self._accumulate_usage(usage, raw_usage) if response.has_tool_calls: if hook.wants_streaming(): @@ -158,13 +137,20 @@ class AgentRunner: await hook.before_execute_tools(context) - results, new_events, fatal_error = await self._execute_tools(spec, response.tool_calls) + results, new_events, fatal_error = await self._execute_tools( + spec, + response.tool_calls, + external_lookup_counts, + ) tool_events.extend(new_events) context.tool_results = list(results) context.tool_events = list(new_events) if fatal_error is not None: error = f"Error: {type(fatal_error).__name__}: {fatal_error}" + final_content = error stop_reason = "tool_error" + self._append_final_message(messages, final_content) + context.final_content = final_content context.error = error context.stop_reason = stop_reason await hook.after_iteration(context) @@ -178,6 +164,7 @@ class AgentRunner: "content": self._normalize_tool_result( spec, tool_call.id, + tool_call.name, result, ), } @@ -197,10 +184,27 @@ class AgentRunner: await hook.after_iteration(context) continue + clean = hook.finalize_content(context, response.content) + if response.finish_reason != "error" and is_blank_text(clean): + logger.warning( + "Empty final response on turn {} for {}; retrying with explicit finalization prompt", + iteration, + spec.session_key or "default", + ) + if hook.wants_streaming(): + await hook.on_stream_end(context, resuming=False) + response = await self._request_finalization_retry(spec, messages_for_model) + retry_usage = self._usage_dict(response.usage) + self._accumulate_usage(usage, retry_usage) + raw_usage = self._merge_usage(raw_usage, retry_usage) + context.response = response + context.usage = dict(raw_usage) + context.tool_calls = list(response.tool_calls) + clean = hook.finalize_content(context, response.content) + if hook.wants_streaming(): await hook.on_stream_end(context, resuming=False) - clean = hook.finalize_content(context, response.content) if response.finish_reason == "error": final_content = clean or spec.error_message or _DEFAULT_ERROR_MESSAGE stop_reason = "error" @@ -211,6 +215,16 @@ class AgentRunner: context.stop_reason = stop_reason await hook.after_iteration(context) break + if is_blank_text(clean): + final_content = EMPTY_FINAL_RESPONSE_MESSAGE + stop_reason = "empty_final_response" + error = final_content + self._append_final_message(messages, final_content) + context.final_content = final_content + context.error = error + context.stop_reason = stop_reason + await hook.after_iteration(context) + break messages.append(build_assistant_message( clean, @@ -249,22 +263,101 @@ class AgentRunner: tool_events=tool_events, ) + def _build_request_kwargs( + self, + spec: AgentRunSpec, + messages: list[dict[str, Any]], + *, + tools: list[dict[str, Any]] | None, + ) -> dict[str, Any]: + kwargs: dict[str, Any] = { + "messages": messages, + "tools": tools, + "model": spec.model, + "retry_mode": spec.provider_retry_mode, + "on_retry_wait": spec.progress_callback, + } + if spec.temperature is not None: + kwargs["temperature"] = spec.temperature + if spec.max_tokens is not None: + kwargs["max_tokens"] = spec.max_tokens + if spec.reasoning_effort is not None: + kwargs["reasoning_effort"] = spec.reasoning_effort + return kwargs + + async def _request_model( + self, + spec: AgentRunSpec, + messages: list[dict[str, Any]], + hook: AgentHook, + context: AgentHookContext, + ): + kwargs = self._build_request_kwargs( + spec, + messages, + tools=spec.tools.get_definitions(), + ) + if hook.wants_streaming(): + async def _stream(delta: str) -> None: + await hook.on_stream(context, delta) + + return await self.provider.chat_stream_with_retry( + **kwargs, + on_content_delta=_stream, + ) + return await self.provider.chat_with_retry(**kwargs) + + async def _request_finalization_retry( + self, + spec: AgentRunSpec, + messages: list[dict[str, Any]], + ): + retry_messages = list(messages) + retry_messages.append(build_finalization_retry_message()) + kwargs = self._build_request_kwargs(spec, retry_messages, tools=None) + return await self.provider.chat_with_retry(**kwargs) + + @staticmethod + def _usage_dict(usage: dict[str, Any] | None) -> dict[str, int]: + if not usage: + return {} + result: dict[str, int] = {} + for key, value in usage.items(): + try: + result[key] = int(value or 0) + except (TypeError, ValueError): + continue + return result + + @staticmethod + def _accumulate_usage(target: dict[str, int], addition: dict[str, int]) -> None: + for key, value in addition.items(): + target[key] = target.get(key, 0) + value + + @staticmethod + def _merge_usage(left: dict[str, int], right: dict[str, int]) -> dict[str, int]: + merged = dict(left) + for key, value in right.items(): + merged[key] = merged.get(key, 0) + value + return merged + async def _execute_tools( self, spec: AgentRunSpec, tool_calls: list[ToolCallRequest], + external_lookup_counts: dict[str, int], ) -> tuple[list[Any], list[dict[str, str]], BaseException | None]: batches = self._partition_tool_batches(spec, tool_calls) tool_results: list[tuple[Any, dict[str, str], BaseException | None]] = [] for batch in batches: if spec.concurrent_tools and len(batch) > 1: tool_results.extend(await asyncio.gather(*( - self._run_tool(spec, tool_call) + self._run_tool(spec, tool_call, external_lookup_counts) for tool_call in batch ))) else: for tool_call in batch: - tool_results.append(await self._run_tool(spec, tool_call)) + tool_results.append(await self._run_tool(spec, tool_call, external_lookup_counts)) results: list[Any] = [] events: list[dict[str, str]] = [] @@ -280,8 +373,23 @@ class AgentRunner: self, spec: AgentRunSpec, tool_call: ToolCallRequest, + external_lookup_counts: dict[str, int], ) -> tuple[Any, dict[str, str], BaseException | None]: _HINT = "\n\n[Analyze the error above and try a different approach.]" + lookup_error = repeated_external_lookup_error( + tool_call.name, + tool_call.arguments, + external_lookup_counts, + ) + if lookup_error: + event = { + "name": tool_call.name, + "status": "error", + "detail": "repeated external lookup blocked", + } + if spec.fail_on_tool_error: + return lookup_error + _HINT, event, RuntimeError(lookup_error) + return lookup_error + _HINT, event, None prepare_call = getattr(spec.tools, "prepare_call", None) tool, params, prep_error = None, tool_call.arguments, None if callable(prepare_call): @@ -361,8 +469,10 @@ class AgentRunner: self, spec: AgentRunSpec, tool_call_id: str, + tool_name: str, result: Any, ) -> Any: + result = ensure_nonempty_tool_result(tool_name, result) try: content = maybe_persist_tool_result( spec.workspace, @@ -395,6 +505,7 @@ class AgentRunner: normalized = self._normalize_tool_result( spec, str(message.get("tool_call_id") or f"tool_{idx}"), + str(message.get("name") or "tool"), message.get("content"), ) if normalized != message.get("content"): diff --git a/nanobot/api/server.py b/nanobot/api/server.py index 9494b6e3..2bfeddd0 100644 --- a/nanobot/api/server.py +++ b/nanobot/api/server.py @@ -14,6 +14,8 @@ from typing import Any from aiohttp import web from loguru import logger +from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE + API_SESSION_KEY = "api:default" API_CHAT_ID = "default" @@ -98,7 +100,7 @@ async def handle_chat_completions(request: web.Request) -> web.Response: logger.info("API request session_key={} content={}", session_key, user_content[:80]) - _FALLBACK = "I've completed processing but have no response to give." + _FALLBACK = EMPTY_FINAL_RESPONSE_MESSAGE try: async with session_lock: diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index fa3e423b..9e0a69d5 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -120,7 +120,7 @@ def find_legal_message_start(messages: list[dict[str, Any]]) -> int: return start -def _stringify_text_blocks(content: list[dict[str, Any]]) -> str | None: +def stringify_text_blocks(content: list[dict[str, Any]]) -> str | None: parts: list[str] = [] for block in content: if not isinstance(block, dict): @@ -201,7 +201,7 @@ def maybe_persist_tool_result( if isinstance(content, str): text_payload = content elif isinstance(content, list): - text_payload = _stringify_text_blocks(content) + text_payload = stringify_text_blocks(content) if text_payload is None: return content suffix = "json" diff --git a/nanobot/utils/runtime.py b/nanobot/utils/runtime.py new file mode 100644 index 00000000..7164629c --- /dev/null +++ b/nanobot/utils/runtime.py @@ -0,0 +1,88 @@ +"""Runtime-specific helper functions and constants.""" + +from __future__ import annotations + +from typing import Any + +from loguru import logger + +from nanobot.utils.helpers import stringify_text_blocks + +_MAX_REPEAT_EXTERNAL_LOOKUPS = 2 + +EMPTY_FINAL_RESPONSE_MESSAGE = ( + "I completed the tool steps but couldn't produce a final answer. " + "Please try again or narrow the task." +) + +FINALIZATION_RETRY_PROMPT = ( + "You have already finished the tool work. Do not call any more tools. " + "Using only the conversation and tool results above, provide the final answer for the user now." +) + + +def empty_tool_result_message(tool_name: str) -> str: + """Short prompt-safe marker for tools that completed without visible output.""" + return f"({tool_name} completed with no output)" + + +def ensure_nonempty_tool_result(tool_name: str, content: Any) -> Any: + """Replace semantically empty tool results with a short marker string.""" + if content is None: + return empty_tool_result_message(tool_name) + if isinstance(content, str) and not content.strip(): + return empty_tool_result_message(tool_name) + if isinstance(content, list): + if not content: + return empty_tool_result_message(tool_name) + text_payload = stringify_text_blocks(content) + if text_payload is not None and not text_payload.strip(): + return empty_tool_result_message(tool_name) + return content + + +def is_blank_text(content: str | None) -> bool: + """True when *content* is missing or only whitespace.""" + return content is None or not content.strip() + + +def build_finalization_retry_message() -> dict[str, str]: + """A short no-tools-allowed prompt for final answer recovery.""" + return {"role": "user", "content": FINALIZATION_RETRY_PROMPT} + + +def external_lookup_signature(tool_name: str, arguments: dict[str, Any]) -> str | None: + """Stable signature for repeated external lookups we want to throttle.""" + if tool_name == "web_fetch": + url = str(arguments.get("url") or "").strip() + if url: + return f"web_fetch:{url.lower()}" + if tool_name == "web_search": + query = str(arguments.get("query") or arguments.get("search_term") or "").strip() + if query: + return f"web_search:{query.lower()}" + return None + + +def repeated_external_lookup_error( + tool_name: str, + arguments: dict[str, Any], + seen_counts: dict[str, int], +) -> str | None: + """Block repeated external lookups after a small retry budget.""" + signature = external_lookup_signature(tool_name, arguments) + if signature is None: + return None + count = seen_counts.get(signature, 0) + 1 + seen_counts[signature] = count + if count <= _MAX_REPEAT_EXTERNAL_LOOKUPS: + return None + logger.warning( + "Blocking repeated external lookup {} on attempt {}", + signature[:160], + count, + ) + return ( + "Error: repeated external lookup blocked. " + "Use the results you already have to answer, or try a meaningfully different source." + ) diff --git a/tests/agent/test_runner.py b/tests/agent/test_runner.py index 9009480e..dcdd1503 100644 --- a/tests/agent/test_runner.py +++ b/tests/agent/test_runner.py @@ -385,6 +385,44 @@ def test_persist_tool_result_logs_cleanup_failures(monkeypatch, tmp_path): assert warnings and "Failed to clean stale tool result buckets" in warnings[0] +@pytest.mark.asyncio +async def test_runner_replaces_empty_tool_result_with_marker(): + from nanobot.agent.runner import AgentRunSpec, AgentRunner + + provider = MagicMock() + captured_second_call: list[dict] = [] + call_count = {"n": 0} + + async def chat_with_retry(*, messages, **kwargs): + call_count["n"] += 1 + if call_count["n"] == 1: + return LLMResponse( + content="working", + tool_calls=[ToolCallRequest(id="call_1", name="noop", arguments={})], + usage={}, + ) + captured_second_call[:] = messages + return LLMResponse(content="done", tool_calls=[], usage={}) + + provider.chat_with_retry = chat_with_retry + tools = MagicMock() + tools.get_definitions.return_value = [] + tools.execute = AsyncMock(return_value="") + + runner = AgentRunner(provider) + result = await runner.run(AgentRunSpec( + initial_messages=[{"role": "user", "content": "do task"}], + tools=tools, + model="test-model", + max_iterations=2, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + )) + + assert result.final_content == "done" + tool_message = next(msg for msg in captured_second_call if msg.get("role") == "tool") + assert tool_message["content"] == "(noop completed with no output)" + + @pytest.mark.asyncio async def test_runner_uses_raw_messages_when_context_governance_fails(): from nanobot.agent.runner import AgentRunSpec, AgentRunner @@ -418,6 +456,75 @@ async def test_runner_uses_raw_messages_when_context_governance_fails(): assert captured_messages == initial_messages +@pytest.mark.asyncio +async def test_runner_retries_empty_final_response_with_summary_prompt(): + from nanobot.agent.runner import AgentRunSpec, AgentRunner + + provider = MagicMock() + calls: list[dict] = [] + + async def chat_with_retry(*, messages, tools=None, **kwargs): + calls.append({"messages": messages, "tools": tools}) + if len(calls) == 1: + return LLMResponse( + content=None, + tool_calls=[], + usage={"prompt_tokens": 10, "completion_tokens": 1}, + ) + return LLMResponse( + content="final answer", + tool_calls=[], + usage={"prompt_tokens": 3, "completion_tokens": 7}, + ) + + provider.chat_with_retry = chat_with_retry + tools = MagicMock() + tools.get_definitions.return_value = [] + + runner = AgentRunner(provider) + result = await runner.run(AgentRunSpec( + initial_messages=[{"role": "user", "content": "do task"}], + tools=tools, + model="test-model", + max_iterations=1, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + )) + + assert result.final_content == "final answer" + assert len(calls) == 2 + assert calls[1]["tools"] is None + assert "Do not call any more tools" in calls[1]["messages"][-1]["content"] + assert result.usage["prompt_tokens"] == 13 + assert result.usage["completion_tokens"] == 8 + + +@pytest.mark.asyncio +async def test_runner_uses_specific_message_after_empty_finalization_retry(): + from nanobot.agent.runner import AgentRunSpec, AgentRunner + from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE + + provider = MagicMock() + + async def chat_with_retry(*, messages, **kwargs): + return LLMResponse(content=None, tool_calls=[], usage={}) + + provider.chat_with_retry = chat_with_retry + tools = MagicMock() + tools.get_definitions.return_value = [] + + runner = AgentRunner(provider) + result = await runner.run(AgentRunSpec( + initial_messages=[{"role": "user", "content": "do task"}], + tools=tools, + model="test-model", + max_iterations=1, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + )) + + assert result.final_content == EMPTY_FINAL_RESPONSE_MESSAGE + assert result.stop_reason == "empty_final_response" + + def test_snip_history_drops_orphaned_tool_results_from_trimmed_slice(monkeypatch): from nanobot.agent.runner import AgentRunSpec, AgentRunner @@ -564,6 +671,7 @@ async def test_runner_batches_read_only_tools_before_exclusive_work(): ToolCallRequest(id="ro2", name="read_b", arguments={}), ToolCallRequest(id="rw1", name="write_a", arguments={}), ], + {}, ) assert shared_events[0:2] == ["start:read_a", "start:read_b"] @@ -573,6 +681,48 @@ async def test_runner_batches_read_only_tools_before_exclusive_work(): assert shared_events[-2:] == ["start:write_a", "end:write_a"] +@pytest.mark.asyncio +async def test_runner_blocks_repeated_external_fetches(): + from nanobot.agent.runner import AgentRunSpec, AgentRunner + + provider = MagicMock() + captured_final_call: list[dict] = [] + call_count = {"n": 0} + + async def chat_with_retry(*, messages, **kwargs): + call_count["n"] += 1 + if call_count["n"] <= 3: + return LLMResponse( + content="working", + tool_calls=[ToolCallRequest(id=f"call_{call_count['n']}", name="web_fetch", arguments={"url": "https://example.com"})], + usage={}, + ) + captured_final_call[:] = messages + return LLMResponse(content="done", tool_calls=[], usage={}) + + provider.chat_with_retry = chat_with_retry + tools = MagicMock() + tools.get_definitions.return_value = [] + tools.execute = AsyncMock(return_value="page content") + + runner = AgentRunner(provider) + result = await runner.run(AgentRunSpec( + initial_messages=[{"role": "user", "content": "research task"}], + tools=tools, + model="test-model", + max_iterations=4, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + )) + + assert result.final_content == "done" + assert tools.execute.await_count == 2 + blocked_tool_message = [ + msg for msg in captured_final_call + if msg.get("role") == "tool" and msg.get("tool_call_id") == "call_3" + ][0] + assert "repeated external lookup blocked" in blocked_tool_message["content"] + + @pytest.mark.asyncio async def test_loop_max_iterations_message_stays_stable(tmp_path): loop = _make_loop(tmp_path) @@ -622,6 +772,57 @@ async def test_loop_stream_filter_handles_think_only_prefix_without_crashing(tmp assert endings == [False] +@pytest.mark.asyncio +async def test_loop_retries_think_only_final_response(tmp_path): + loop = _make_loop(tmp_path) + call_count = {"n": 0} + + async def chat_with_retry(**kwargs): + call_count["n"] += 1 + if call_count["n"] == 1: + return LLMResponse(content="hidden", tool_calls=[], usage={}) + return LLMResponse(content="Recovered answer", tool_calls=[], usage={}) + + loop.provider.chat_with_retry = chat_with_retry + + final_content, _, _ = await loop._run_agent_loop([]) + + assert final_content == "Recovered answer" + assert call_count["n"] == 2 + + +@pytest.mark.asyncio +async def test_runner_tool_error_sets_final_content(): + from nanobot.agent.runner import AgentRunSpec, AgentRunner + + provider = MagicMock() + + async def chat_with_retry(*, messages, **kwargs): + return LLMResponse( + content="working", + tool_calls=[ToolCallRequest(id="call_1", name="read_file", arguments={"path": "x"})], + usage={}, + ) + + provider.chat_with_retry = chat_with_retry + tools = MagicMock() + tools.get_definitions.return_value = [] + tools.execute = AsyncMock(side_effect=RuntimeError("boom")) + + runner = AgentRunner(provider) + result = await runner.run(AgentRunSpec( + initial_messages=[{"role": "user", "content": "do task"}], + tools=tools, + model="test-model", + max_iterations=1, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + fail_on_tool_error=True, + )) + + assert result.final_content == "Error: RuntimeError: boom" + assert result.stop_reason == "tool_error" + + @pytest.mark.asyncio async def test_subagent_max_iterations_announces_existing_fallback(tmp_path, monkeypatch): from nanobot.agent.subagent import SubagentManager diff --git a/tests/test_openai_api.py b/tests/test_openai_api.py index 42fec33e..2d4ae858 100644 --- a/tests/test_openai_api.py +++ b/tests/test_openai_api.py @@ -347,6 +347,8 @@ async def test_empty_response_retry_then_success(aiohttp_client) -> None: @pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") @pytest.mark.asyncio async def test_empty_response_falls_back(aiohttp_client) -> None: + from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE + call_count = 0 async def always_empty(content, session_key="", channel="", chat_id=""): @@ -367,5 +369,5 @@ async def test_empty_response_falls_back(aiohttp_client) -> None: ) assert resp.status == 200 body = await resp.json() - assert body["choices"][0]["message"]["content"] == "I've completed processing but have no response to give." + assert body["choices"][0]["message"]["content"] == EMPTY_FINAL_RESPONSE_MESSAGE assert call_count == 2 From cf6c9793392e3816f093f8673abcb44c40db8ee7 Mon Sep 17 00:00:00 2001 From: Lingao Meng Date: Fri, 3 Apr 2026 14:40:31 +0800 Subject: [PATCH 78/91] feat(provider): add Xiaomi MiMo LLM support Register xiaomi_mimo as an OpenAI-compatible provider with its API base URL, add xiaomi_mimo to the provider config schema, and document it in README. Signed-off-by: Lingao Meng --- README.md | 1 + nanobot/config/schema.py | 1 + nanobot/providers/base.py | 2 +- nanobot/providers/registry.py | 9 +++++++++ 4 files changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8a8c864d..e6f266be 100644 --- a/README.md +++ b/README.md @@ -875,6 +875,7 @@ Config file: `~/.nanobot/config.json` | `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | | `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) | | `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) | +| `mimo` | LLM (MiMo) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) | | `ollama` | LLM (local, Ollama) | — | | `mistral` | LLM | [docs.mistral.ai](https://docs.mistral.ai/) | | `stepfun` | LLM (Step Fun/阶跃星辰) | [platform.stepfun.com](https://platform.stepfun.com) | diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 602b8a91..e4666355 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -81,6 +81,7 @@ class ProvidersConfig(Base): minimax: ProviderConfig = Field(default_factory=ProviderConfig) mistral: ProviderConfig = Field(default_factory=ProviderConfig) stepfun: ProviderConfig = Field(default_factory=ProviderConfig) # Step Fun (阶跃星辰) + xiaomi_mimo: ProviderConfig = Field(default_factory=ProviderConfig) # Xiaomi MIMO (小米) aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动) volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎) diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index 852e9c97..b666d0f3 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -49,7 +49,7 @@ class LLMResponse: tool_calls: list[ToolCallRequest] = field(default_factory=list) finish_reason: str = "stop" usage: dict[str, int] = field(default_factory=dict) - reasoning_content: str | None = None # Kimi, DeepSeek-R1 etc. + reasoning_content: str | None = None # Kimi, DeepSeek-R1, MiMo etc. thinking_blocks: list[dict] | None = None # Anthropic extended thinking @property diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 8435005e..75b82c1e 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -297,6 +297,15 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( backend="openai_compat", default_api_base="https://api.stepfun.com/v1", ), + # Xiaomi MIMO (小米): OpenAI-compatible API + ProviderSpec( + name="xiaomi_mimo", + keywords=("xiaomi_mimo", "mimo"), + env_key="XIAOMIMIMO_API_KEY", + display_name="Xiaomi MIMO", + backend="openai_compat", + default_api_base="https://api.xiaomimimo.com/v1", + ), # === Local deployment (matched by config key, NOT by api_base) ========= # vLLM / any OpenAI-compatible local server ProviderSpec( From 3c3a72ef82b6d93073cf4f260f803dbbbc443b4f Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Fri, 3 Apr 2026 16:02:23 +0000 Subject: [PATCH 79/91] update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index fce6e07f..08217c5b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .assets .docs .env +.web *.pyc dist/ build/ From cb84f2b908e5219502dca0ae639fb92196c7f307 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Fri, 3 Apr 2026 16:18:36 +0000 Subject: [PATCH 80/91] docs: update nanobot news section --- README.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 8a8c864d..60714b34 100644 --- a/README.md +++ b/README.md @@ -20,13 +20,20 @@ ## 📢 News -> [!IMPORTANT] -> **Security note:** Due to `litellm` supply chain poisoning, **please check your Python environment ASAP** and refer to this [advisory](https://github.com/HKUDS/nanobot/discussions/2445) for details. We have fully removed the `litellm` since **v0.1.4.post6**. - +- **2026-04-02** 🧱 **Long-running tasks** run more reliably — core runtime hardening. +- **2026-04-01** 🔑 GitHub Copilot auth restored; stricter workspace paths; OpenRouter Claude caching fix. +- **2026-03-31** 🛰️ WeChat multimodal alignment, Discord/Matrix polish, Python SDK facade, MCP and tool fixes. +- **2026-03-30** 🧩 OpenAI-compatible API tightened; composable agent lifecycle hooks. +- **2026-03-29** 💬 WeChat voice, typing, QR/media resilience; fixed-session OpenAI-compatible API. +- **2026-03-28** 📚 Provider docs refresh; skill template wording fix. - **2026-03-27** 🚀 Released **v0.1.4.post6** — architecture decoupling, litellm removal, end-to-end streaming, WeChat channel, and a security fix. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post6) for details. - **2026-03-26** 🏗️ Agent runner extracted and lifecycle hooks unified; stream delta coalescing at boundaries. - **2026-03-25** 🌏 StepFun provider, configurable timezone, Gemini thought signatures. - **2026-03-24** 🔧 WeChat compatibility, Feishu CardKit streaming, test suite restructured. + +
+Earlier news + - **2026-03-23** 🔧 Command routing refactored for plugins, WhatsApp/WeChat media, unified channel login CLI. - **2026-03-22** ⚡ End-to-end streaming, WeChat channel, Anthropic cache optimization, `/status` command. - **2026-03-21** 🔒 Replace `litellm` with native `openai` + `anthropic` SDKs. Please see [commit](https://github.com/HKUDS/nanobot/commit/3dfdab7). @@ -34,10 +41,6 @@ - **2026-03-19** 💬 Telegram gets more resilient under load; Feishu now renders code blocks properly. - **2026-03-18** 📷 Telegram can now send media via URL. Cron schedules show human-readable details. - **2026-03-17** ✨ Feishu formatting glow-up, Slack reacts when done, custom endpoints support extra headers, and image handling is more reliable. - -
-Earlier news - - **2026-03-16** 🚀 Released **v0.1.4.post5** — a refinement-focused release with stronger reliability and channel support, and a more dependable day-to-day experience. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post5) for details. - **2026-03-15** 🧩 DingTalk rich media, smarter built-in skills, and cleaner model compatibility. - **2026-03-14** 💬 Channel plugins, Feishu replies, and steadier MCP, QQ, and media handling. From 0fa82298d315150254bc6ccaac364f2504941a46 Mon Sep 17 00:00:00 2001 From: Flo Date: Wed, 1 Apr 2026 09:00:52 +0300 Subject: [PATCH 81/91] fix(telegram): support commands with bot username suffix in groups (#2553) * fix(telegram): support commands with bot username suffix in groups * fix(command): preserve metadata in builtin command responses --- nanobot/channels/telegram.py | 21 +++++++++++++-------- nanobot/command/builtin.py | 15 +++++++++++---- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 916b9ba6..439d1c4d 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -275,13 +275,10 @@ class TelegramChannel(BaseChannel): self._app = builder.build() self._app.add_error_handler(self._on_error) - # Add command handlers - self._app.add_handler(CommandHandler("start", self._on_start)) - self._app.add_handler(CommandHandler("new", self._forward_command)) - self._app.add_handler(CommandHandler("stop", self._forward_command)) - self._app.add_handler(CommandHandler("restart", self._forward_command)) - self._app.add_handler(CommandHandler("status", self._forward_command)) - self._app.add_handler(CommandHandler("help", self._on_help)) + # Add command handlers (using Regex to support @username suffixes before bot initialization) + self._app.add_handler(MessageHandler(filters.Regex(r"^/start(?:@\w+)?$"), self._on_start)) + self._app.add_handler(MessageHandler(filters.Regex(r"^/(new|stop|restart|status)(?:@\w+)?$"), self._forward_command)) + self._app.add_handler(MessageHandler(filters.Regex(r"^/help(?:@\w+)?$"), self._on_help)) # Add message handler for text, photos, voice, documents self._app.add_handler( @@ -765,10 +762,18 @@ class TelegramChannel(BaseChannel): message = update.message user = update.effective_user self._remember_thread_context(message) + + # Strip @bot_username suffix if present + content = message.text or "" + if content.startswith("/") and "@" in content: + cmd_part, *rest = content.split(" ", 1) + cmd_part = cmd_part.split("@")[0] + content = f"{cmd_part} {rest[0]}" if rest else cmd_part + await self._handle_message( sender_id=self._sender_id(user), chat_id=str(message.chat_id), - content=message.text or "", + content=content, metadata=self._build_message_metadata(message, user), session_key=self._derive_topic_session_key(message), ) diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index 64339705..05d4fc16 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -26,7 +26,10 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage: sub_cancelled = await loop.subagents.cancel_by_session(msg.session_key) total = cancelled + sub_cancelled content = f"Stopped {total} task(s)." if total else "No active task to stop." - return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=content) + return OutboundMessage( + channel=msg.channel, chat_id=msg.chat_id, content=content, + metadata=dict(msg.metadata or {}) + ) async def cmd_restart(ctx: CommandContext) -> OutboundMessage: @@ -38,7 +41,10 @@ async def cmd_restart(ctx: CommandContext) -> OutboundMessage: os.execv(sys.executable, [sys.executable, "-m", "nanobot"] + sys.argv[1:]) asyncio.create_task(_do_restart()) - return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content="Restarting...") + return OutboundMessage( + channel=msg.channel, chat_id=msg.chat_id, content="Restarting...", + metadata=dict(msg.metadata or {}) + ) async def cmd_status(ctx: CommandContext) -> OutboundMessage: @@ -62,7 +68,7 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage: session_msg_count=len(session.get_history(max_messages=0)), context_tokens_estimate=ctx_est, ), - metadata={"render_as": "text"}, + metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"}, ) @@ -79,6 +85,7 @@ async def cmd_new(ctx: CommandContext) -> OutboundMessage: return OutboundMessage( channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content="New session started.", + metadata=dict(ctx.msg.metadata or {}) ) @@ -88,7 +95,7 @@ async def cmd_help(ctx: CommandContext) -> OutboundMessage: channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content=build_help_text(), - metadata={"render_as": "text"}, + metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"}, ) From 0709fda568887d577412166a6a707de07d53855b Mon Sep 17 00:00:00 2001 From: Flo Date: Wed, 1 Apr 2026 09:13:08 +0300 Subject: [PATCH 82/91] fix(telegram): handle RetryAfter delay internally in channel (#2552) --- nanobot/channels/telegram.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 439d1c4d..8cb85844 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -432,7 +432,9 @@ class TelegramChannel(BaseChannel): await self._send_text(chat_id, chunk, reply_params, thread_kwargs) async def _call_with_retry(self, fn, *args, **kwargs): - """Call an async Telegram API function with retry on pool/network timeout.""" + """Call an async Telegram API function with retry on pool/network timeout and RetryAfter.""" + from telegram.error import RetryAfter + for attempt in range(1, _SEND_MAX_RETRIES + 1): try: return await fn(*args, **kwargs) @@ -445,6 +447,15 @@ class TelegramChannel(BaseChannel): attempt, _SEND_MAX_RETRIES, delay, ) await asyncio.sleep(delay) + except RetryAfter as e: + if attempt == _SEND_MAX_RETRIES: + raise + delay = float(e.retry_after) + logger.warning( + "Telegram Flood Control (attempt {}/{}), retrying in {:.1f}s", + attempt, _SEND_MAX_RETRIES, delay, + ) + await asyncio.sleep(delay) async def _send_text( self, From 2e5308ff28e9857bc99efcd37390970421676d8d Mon Sep 17 00:00:00 2001 From: Flo Date: Wed, 1 Apr 2026 09:14:42 +0300 Subject: [PATCH 83/91] fix(telegram): remove acknowledgment reaction when response completes (#2564) --- nanobot/channels/telegram.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 8cb85844..cacecd73 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -359,9 +359,14 @@ class TelegramChannel(BaseChannel): logger.warning("Telegram bot not running") return - # Only stop typing indicator for final responses + # Only stop typing indicator and remove reaction for final responses if not msg.metadata.get("_progress", False): self._stop_typing(msg.chat_id) + if reply_to_message_id := msg.metadata.get("message_id"): + try: + await self._remove_reaction(msg.chat_id, int(reply_to_message_id)) + except ValueError: + pass try: chat_id = int(msg.chat_id) @@ -506,6 +511,11 @@ class TelegramChannel(BaseChannel): if stream_id is not None and buf.stream_id is not None and buf.stream_id != stream_id: return self._stop_typing(chat_id) + if reply_to_message_id := meta.get("message_id"): + try: + await self._remove_reaction(chat_id, int(reply_to_message_id)) + except ValueError: + pass try: html = _markdown_to_telegram_html(buf.text) await self._call_with_retry( @@ -919,6 +929,19 @@ class TelegramChannel(BaseChannel): except Exception as e: logger.debug("Telegram reaction failed: {}", e) + async def _remove_reaction(self, chat_id: str, message_id: int) -> None: + """Remove emoji reaction from a message (best-effort, non-blocking).""" + if not self._app: + return + try: + await self._app.bot.set_message_reaction( + chat_id=int(chat_id), + message_id=message_id, + reaction=[], + ) + except Exception as e: + logger.debug("Telegram reaction removal failed: {}", e) + async def _typing_loop(self, chat_id: str) -> None: """Repeatedly send 'typing' action until cancelled.""" try: From 49c40e6b31daf932f0486f0cfaed55bd440e21bd Mon Sep 17 00:00:00 2001 From: Flo Date: Wed, 1 Apr 2026 09:16:51 +0300 Subject: [PATCH 84/91] feat(telegram): include author context in reply tags (#2605) (#2606) * feat(telegram): include author context in reply tags (#2605) * fix(telegram): handle missing attributes in reply_user safely --- nanobot/channels/telegram.py | 21 ++++++++++--- tests/channels/test_telegram_channel.py | 39 ++++++++++++++++--------- 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index cacecd73..72d60a19 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -637,8 +637,7 @@ class TelegramChannel(BaseChannel): "reply_to_message_id": getattr(reply_to, "message_id", None) if reply_to else None, } - @staticmethod - def _extract_reply_context(message) -> str | None: + async def _extract_reply_context(self, message) -> str | None: """Extract text from the message being replied to, if any.""" reply = getattr(message, "reply_to_message", None) if not reply: @@ -646,7 +645,21 @@ class TelegramChannel(BaseChannel): text = getattr(reply, "text", None) or getattr(reply, "caption", None) or "" if len(text) > TELEGRAM_REPLY_CONTEXT_MAX_LEN: text = text[:TELEGRAM_REPLY_CONTEXT_MAX_LEN] + "..." - return f"[Reply to: {text}]" if text else None + + if not text: + return None + + bot_id, _ = await self._ensure_bot_identity() + reply_user = getattr(reply, "from_user", None) + + if bot_id and reply_user and getattr(reply_user, "id", None) == bot_id: + return f"[Reply to bot: {text}]" + elif reply_user and getattr(reply_user, "username", None): + return f"[Reply to @{reply_user.username}: {text}]" + elif reply_user and getattr(reply_user, "first_name", None): + return f"[Reply to {reply_user.first_name}: {text}]" + else: + return f"[Reply to: {text}]" async def _download_message_media( self, msg, *, add_failure_content: bool = False @@ -838,7 +851,7 @@ class TelegramChannel(BaseChannel): # Reply context: text and/or media from the replied-to message reply = getattr(message, "reply_to_message", None) if reply is not None: - reply_ctx = self._extract_reply_context(message) + reply_ctx = await self._extract_reply_context(message) reply_media, reply_media_parts = await self._download_message_media(reply) if reply_media: media_paths = reply_media + media_paths diff --git a/tests/channels/test_telegram_channel.py b/tests/channels/test_telegram_channel.py index 972f8ab6..c793b122 100644 --- a/tests/channels/test_telegram_channel.py +++ b/tests/channels/test_telegram_channel.py @@ -647,43 +647,56 @@ async def test_group_policy_open_accepts_plain_group_message() -> None: assert channel._app.bot.get_me_calls == 0 -def test_extract_reply_context_no_reply() -> None: +@pytest.mark.asyncio +async def test_extract_reply_context_no_reply() -> None: """When there is no reply_to_message, _extract_reply_context returns None.""" + channel = TelegramChannel(TelegramConfig(enabled=True, token="123:abc"), MessageBus()) message = SimpleNamespace(reply_to_message=None) - assert TelegramChannel._extract_reply_context(message) is None + assert await channel._extract_reply_context(message) is None -def test_extract_reply_context_with_text() -> None: +@pytest.mark.asyncio +async def test_extract_reply_context_with_text() -> None: """When reply has text, return prefixed string.""" - reply = SimpleNamespace(text="Hello world", caption=None) + channel = TelegramChannel(TelegramConfig(enabled=True, token="123:abc"), MessageBus()) + channel._app = _FakeApp(lambda: None) + reply = SimpleNamespace(text="Hello world", caption=None, from_user=SimpleNamespace(id=2, username="testuser", first_name="Test")) message = SimpleNamespace(reply_to_message=reply) - assert TelegramChannel._extract_reply_context(message) == "[Reply to: Hello world]" + assert await channel._extract_reply_context(message) == "[Reply to @testuser: Hello world]" -def test_extract_reply_context_with_caption_only() -> None: +@pytest.mark.asyncio +async def test_extract_reply_context_with_caption_only() -> None: """When reply has only caption (no text), caption is used.""" - reply = SimpleNamespace(text=None, caption="Photo caption") + channel = TelegramChannel(TelegramConfig(enabled=True, token="123:abc"), MessageBus()) + channel._app = _FakeApp(lambda: None) + reply = SimpleNamespace(text=None, caption="Photo caption", from_user=SimpleNamespace(id=2, username=None, first_name="Test")) message = SimpleNamespace(reply_to_message=reply) - assert TelegramChannel._extract_reply_context(message) == "[Reply to: Photo caption]" + assert await channel._extract_reply_context(message) == "[Reply to Test: Photo caption]" -def test_extract_reply_context_truncation() -> None: +@pytest.mark.asyncio +async def test_extract_reply_context_truncation() -> None: """Reply text is truncated at TELEGRAM_REPLY_CONTEXT_MAX_LEN.""" + channel = TelegramChannel(TelegramConfig(enabled=True, token="123:abc"), MessageBus()) + channel._app = _FakeApp(lambda: None) long_text = "x" * (TELEGRAM_REPLY_CONTEXT_MAX_LEN + 100) - reply = SimpleNamespace(text=long_text, caption=None) + reply = SimpleNamespace(text=long_text, caption=None, from_user=SimpleNamespace(id=2, username=None, first_name=None)) message = SimpleNamespace(reply_to_message=reply) - result = TelegramChannel._extract_reply_context(message) + result = await channel._extract_reply_context(message) assert result is not None assert result.startswith("[Reply to: ") assert result.endswith("...]") assert len(result) == len("[Reply to: ]") + TELEGRAM_REPLY_CONTEXT_MAX_LEN + len("...") -def test_extract_reply_context_no_text_returns_none() -> None: +@pytest.mark.asyncio +async def test_extract_reply_context_no_text_returns_none() -> None: """When reply has no text/caption, _extract_reply_context returns None (media handled separately).""" + channel = TelegramChannel(TelegramConfig(enabled=True, token="123:abc"), MessageBus()) reply = SimpleNamespace(text=None, caption=None) message = SimpleNamespace(reply_to_message=reply) - assert TelegramChannel._extract_reply_context(message) is None + assert await channel._extract_reply_context(message) is None @pytest.mark.asyncio From 06989fd65b606756148817f77bcaa15e257faef2 Mon Sep 17 00:00:00 2001 From: daliu858 Date: Wed, 1 Apr 2026 14:10:54 +0800 Subject: [PATCH 85/91] feat(qq): add configurable instant acknowledgment message (#2561) Add ack_message config field to QQConfig (default: Processing...). When non-empty, sends an instant text reply before agent processing begins, filling the silence gap for users. Uses existing _send_text_only method; failure is logged but never blocks normal message handling. Made-with: Cursor --- nanobot/channels/qq.py | 12 ++ tests/channels/test_qq_ack_message.py | 172 ++++++++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 tests/channels/test_qq_ack_message.py diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index b9d2d64d..bef2cf27 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -134,6 +134,7 @@ class QQConfig(Base): secret: str = "" allow_from: list[str] = Field(default_factory=list) msg_format: Literal["plain", "markdown"] = "plain" + ack_message: str = "⏳ Processing..." # Optional: directory to save inbound attachments. If empty, use nanobot get_media_dir("qq"). media_dir: str = "" @@ -484,6 +485,17 @@ class QQChannel(BaseChannel): if not content and not media_paths: return + if self.config.ack_message: + try: + await self._send_text_only( + chat_id=chat_id, + is_group=is_group, + msg_id=data.id, + content=self.config.ack_message, + ) + except Exception: + logger.debug("QQ ack message failed for chat_id={}", chat_id) + await self._handle_message( sender_id=user_id, chat_id=chat_id, diff --git a/tests/channels/test_qq_ack_message.py b/tests/channels/test_qq_ack_message.py new file mode 100644 index 00000000..0f3a2dbe --- /dev/null +++ b/tests/channels/test_qq_ack_message.py @@ -0,0 +1,172 @@ +"""Tests for QQ channel ack_message feature. + +Covers the four verification points from the PR: +1. C2C message: ack appears instantly +2. Group message: ack appears instantly +3. ack_message set to "": no ack sent +4. Custom ack_message text: correct text delivered +Each test also verifies that normal message processing is not blocked. +""" + +from types import SimpleNamespace + +import pytest + +try: + from nanobot.channels import qq + + QQ_AVAILABLE = getattr(qq, "QQ_AVAILABLE", False) +except ImportError: + QQ_AVAILABLE = False + +if not QQ_AVAILABLE: + pytest.skip("QQ dependencies not installed (qq-botpy)", allow_module_level=True) + +from nanobot.bus.queue import MessageBus +from nanobot.channels.qq import QQChannel, QQConfig + + +class _FakeApi: + def __init__(self) -> None: + self.c2c_calls: list[dict] = [] + self.group_calls: list[dict] = [] + + async def post_c2c_message(self, **kwargs) -> None: + self.c2c_calls.append(kwargs) + + async def post_group_message(self, **kwargs) -> None: + self.group_calls.append(kwargs) + + +class _FakeClient: + def __init__(self) -> None: + self.api = _FakeApi() + + +@pytest.mark.asyncio +async def test_ack_sent_on_c2c_message() -> None: + """Ack is sent immediately for C2C messages, then normal processing continues.""" + channel = QQChannel( + QQConfig( + app_id="app", + secret="secret", + allow_from=["*"], + ack_message="⏳ Processing...", + ), + MessageBus(), + ) + channel._client = _FakeClient() + + data = SimpleNamespace( + id="msg1", + content="hello", + author=SimpleNamespace(user_openid="user1"), + attachments=[], + ) + await channel._on_message(data, is_group=False) + + assert len(channel._client.api.c2c_calls) >= 1 + ack_call = channel._client.api.c2c_calls[0] + assert ack_call["content"] == "⏳ Processing..." + assert ack_call["openid"] == "user1" + assert ack_call["msg_id"] == "msg1" + assert ack_call["msg_type"] == 0 + + msg = await channel.bus.consume_inbound() + assert msg.content == "hello" + assert msg.sender_id == "user1" + + +@pytest.mark.asyncio +async def test_ack_sent_on_group_message() -> None: + """Ack is sent immediately for group messages, then normal processing continues.""" + channel = QQChannel( + QQConfig( + app_id="app", + secret="secret", + allow_from=["*"], + ack_message="⏳ Processing...", + ), + MessageBus(), + ) + channel._client = _FakeClient() + + data = SimpleNamespace( + id="msg2", + content="hello group", + group_openid="group123", + author=SimpleNamespace(member_openid="user1"), + attachments=[], + ) + await channel._on_message(data, is_group=True) + + assert len(channel._client.api.group_calls) >= 1 + ack_call = channel._client.api.group_calls[0] + assert ack_call["content"] == "⏳ Processing..." + assert ack_call["group_openid"] == "group123" + assert ack_call["msg_id"] == "msg2" + assert ack_call["msg_type"] == 0 + + msg = await channel.bus.consume_inbound() + assert msg.content == "hello group" + assert msg.chat_id == "group123" + + +@pytest.mark.asyncio +async def test_no_ack_when_ack_message_empty() -> None: + """Setting ack_message to empty string disables the ack entirely.""" + channel = QQChannel( + QQConfig( + app_id="app", + secret="secret", + allow_from=["*"], + ack_message="", + ), + MessageBus(), + ) + channel._client = _FakeClient() + + data = SimpleNamespace( + id="msg3", + content="hello", + author=SimpleNamespace(user_openid="user1"), + attachments=[], + ) + await channel._on_message(data, is_group=False) + + assert len(channel._client.api.c2c_calls) == 0 + assert len(channel._client.api.group_calls) == 0 + + msg = await channel.bus.consume_inbound() + assert msg.content == "hello" + + +@pytest.mark.asyncio +async def test_custom_ack_message_text() -> None: + """Custom Chinese ack_message text is delivered correctly.""" + custom = "正在处理中,请稍候..." + channel = QQChannel( + QQConfig( + app_id="app", + secret="secret", + allow_from=["*"], + ack_message=custom, + ), + MessageBus(), + ) + channel._client = _FakeClient() + + data = SimpleNamespace( + id="msg4", + content="test input", + author=SimpleNamespace(user_openid="user1"), + attachments=[], + ) + await channel._on_message(data, is_group=False) + + assert len(channel._client.api.c2c_calls) >= 1 + ack_call = channel._client.api.c2c_calls[0] + assert ack_call["content"] == custom + + msg = await channel.bus.consume_inbound() + assert msg.content == "test input" From 8b4d6b6512068519e5e887693efc96363c1257b5 Mon Sep 17 00:00:00 2001 From: Flo Date: Wed, 1 Apr 2026 09:42:18 +0300 Subject: [PATCH 86/91] fix(tools): strip blocks from message tool content (#2621) --- nanobot/agent/tools/message.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py index 3ac81324..52002073 100644 --- a/nanobot/agent/tools/message.py +++ b/nanobot/agent/tools/message.py @@ -84,6 +84,9 @@ class MessageTool(Tool): media: list[str] | None = None, **kwargs: Any ) -> str: + from nanobot.utils.helpers import strip_think + content = strip_think(content) + channel = channel or self._default_channel chat_id = chat_id or self._default_chat_id # Only inherit default message_id when targeting the same channel+chat. From 3ada54fa5d2eea8df33dbdad96f74e9e13dddbee Mon Sep 17 00:00:00 2001 From: Flo Date: Wed, 1 Apr 2026 11:47:41 +0300 Subject: [PATCH 87/91] fix(telegram): change drop_pending_updates to False on startup (#2686) --- nanobot/channels/telegram.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 72d60a19..a6bd810f 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -310,7 +310,7 @@ class TelegramChannel(BaseChannel): # Start polling (this runs until stopped) await self._app.updater.start_polling( allowed_updates=["message"], - drop_pending_updates=True # Ignore old messages on startup + drop_pending_updates=False # Process pending messages on startup ) # Keep running until stopped From 210643ed687f66c44e30a905c228119f14d70dba Mon Sep 17 00:00:00 2001 From: Lingao Meng Date: Fri, 3 Apr 2026 14:40:40 +0800 Subject: [PATCH 88/91] feat(provider): support reasoning_content in OpenAI compat provider Extract reasoning_content from both non-streaming and streaming responses in OpenAICompatProvider. Accumulate chunks during streaming and merge into LLMResponse, enabling reasoning chain display for models like MiMo and DeepSeek-R1. Signed-off-by: Lingao Meng --- nanobot/providers/openai_compat_provider.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index 3e0a34fb..13b0eb78 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -385,9 +385,13 @@ class OpenAICompatProvider(LLMProvider): content = self._extract_text_content( response_map.get("content") or response_map.get("output_text") ) + reasoning_content = self._extract_text_content( + response_map.get("reasoning_content") + ) if content is not None: return LLMResponse( content=content, + reasoning_content=reasoning_content, finish_reason=str(response_map.get("finish_reason") or "stop"), usage=self._extract_usage(response_map), ) @@ -482,6 +486,7 @@ class OpenAICompatProvider(LLMProvider): @classmethod def _parse_chunks(cls, chunks: list[Any]) -> LLMResponse: content_parts: list[str] = [] + reasoning_parts: list[str] = [] tc_bufs: dict[int, dict[str, Any]] = {} finish_reason = "stop" usage: dict[str, int] = {} @@ -535,6 +540,9 @@ class OpenAICompatProvider(LLMProvider): text = cls._extract_text_content(delta.get("content")) if text: content_parts.append(text) + text = cls._extract_text_content(delta.get("reasoning_content")) + if text: + reasoning_parts.append(text) for idx, tc in enumerate(delta.get("tool_calls") or []): _accum_tc(tc, idx) usage = cls._extract_usage(chunk_map) or usage @@ -549,6 +557,10 @@ class OpenAICompatProvider(LLMProvider): delta = choice.delta if delta and delta.content: content_parts.append(delta.content) + if delta: + reasoning = getattr(delta, "reasoning_content", None) + if reasoning: + reasoning_parts.append(reasoning) for tc in (delta.tool_calls or []) if delta else []: _accum_tc(tc, getattr(tc, "index", 0)) @@ -567,6 +579,7 @@ class OpenAICompatProvider(LLMProvider): ], finish_reason=finish_reason, usage=usage, + reasoning_content="".join(reasoning_parts) or None, ) @staticmethod @@ -630,6 +643,9 @@ class OpenAICompatProvider(LLMProvider): break chunks.append(chunk) if on_content_delta and chunk.choices: + text = getattr(chunk.choices[0].delta, "reasoning_content", None) + if text: + await on_content_delta(text) text = getattr(chunk.choices[0].delta, "content", None) if text: await on_content_delta(text) From a05f83da89f2718e7ffd0bf200120bb5705f0a68 Mon Sep 17 00:00:00 2001 From: Lingao Meng Date: Fri, 3 Apr 2026 15:12:55 +0800 Subject: [PATCH 89/91] test(providers): cover reasoning_content extraction in OpenAI compat provider Add regression tests for the non-streaming (_parse dict branch) and streaming (_parse_chunks dict and SDK-object branches) paths that extract reasoning_content, ensuring the field is populated when present and None when absent. Signed-off-by: Lingao Meng --- tests/providers/test_reasoning_content.py | 128 ++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 tests/providers/test_reasoning_content.py diff --git a/tests/providers/test_reasoning_content.py b/tests/providers/test_reasoning_content.py new file mode 100644 index 00000000..a5856914 --- /dev/null +++ b/tests/providers/test_reasoning_content.py @@ -0,0 +1,128 @@ +"""Tests for reasoning_content extraction in OpenAICompatProvider. + +Covers non-streaming (_parse) and streaming (_parse_chunks) paths for +providers that return a reasoning_content field (e.g. MiMo, DeepSeek-R1). +""" + +from types import SimpleNamespace +from unittest.mock import patch + +from nanobot.providers.openai_compat_provider import OpenAICompatProvider + + +# ── _parse: non-streaming ───────────────────────────────────────────────── + + +def test_parse_dict_extracts_reasoning_content() -> None: + """reasoning_content at message level is surfaced in LLMResponse.""" + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + provider = OpenAICompatProvider() + + response = { + "choices": [{ + "message": { + "content": "42", + "reasoning_content": "Let me think step by step…", + }, + "finish_reason": "stop", + }], + "usage": {"prompt_tokens": 5, "completion_tokens": 10, "total_tokens": 15}, + } + + result = provider._parse(response) + + assert result.content == "42" + assert result.reasoning_content == "Let me think step by step…" + + +def test_parse_dict_reasoning_content_none_when_absent() -> None: + """reasoning_content is None when the response doesn't include it.""" + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + provider = OpenAICompatProvider() + + response = { + "choices": [{ + "message": {"content": "hello"}, + "finish_reason": "stop", + }], + } + + result = provider._parse(response) + + assert result.reasoning_content is None + + +# ── _parse_chunks: streaming dict branch ───────────────────────────────── + + +def test_parse_chunks_dict_accumulates_reasoning_content() -> None: + """reasoning_content deltas in dict chunks are joined into one string.""" + chunks = [ + { + "choices": [{ + "finish_reason": None, + "delta": {"content": None, "reasoning_content": "Step 1. "}, + }], + }, + { + "choices": [{ + "finish_reason": None, + "delta": {"content": None, "reasoning_content": "Step 2."}, + }], + }, + { + "choices": [{ + "finish_reason": "stop", + "delta": {"content": "answer"}, + }], + }, + ] + + result = OpenAICompatProvider._parse_chunks(chunks) + + assert result.content == "answer" + assert result.reasoning_content == "Step 1. Step 2." + + +def test_parse_chunks_dict_reasoning_content_none_when_absent() -> None: + """reasoning_content is None when no chunk contains it.""" + chunks = [ + {"choices": [{"finish_reason": "stop", "delta": {"content": "hi"}}]}, + ] + + result = OpenAICompatProvider._parse_chunks(chunks) + + assert result.content == "hi" + assert result.reasoning_content is None + + +# ── _parse_chunks: streaming SDK-object branch ──────────────────────────── + + +def _make_reasoning_chunk(reasoning: str | None, content: str | None, finish: str | None): + delta = SimpleNamespace(content=content, reasoning_content=reasoning, tool_calls=None) + choice = SimpleNamespace(finish_reason=finish, delta=delta) + return SimpleNamespace(choices=[choice], usage=None) + + +def test_parse_chunks_sdk_accumulates_reasoning_content() -> None: + """reasoning_content on SDK delta objects is joined across chunks.""" + chunks = [ + _make_reasoning_chunk("Think… ", None, None), + _make_reasoning_chunk("Done.", None, None), + _make_reasoning_chunk(None, "result", "stop"), + ] + + result = OpenAICompatProvider._parse_chunks(chunks) + + assert result.content == "result" + assert result.reasoning_content == "Think… Done." + + +def test_parse_chunks_sdk_reasoning_content_none_when_absent() -> None: + """reasoning_content is None when SDK deltas carry no reasoning_content.""" + chunks = [_make_reasoning_chunk(None, "hello", "stop")] + + result = OpenAICompatProvider._parse_chunks(chunks) + + assert result.reasoning_content is None From ba7c07ccf2e81178c107367b048761ab5f4ff4f1 Mon Sep 17 00:00:00 2001 From: imfondof Date: Thu, 2 Apr 2026 16:42:47 +0800 Subject: [PATCH 90/91] fix(restart): send completion notice after channel is ready and unify runtime keys --- nanobot/cli/commands.py | 74 ++++++++++++++++++++++++++-- nanobot/command/builtin.py | 3 ++ nanobot/config/runtime_keys.py | 4 ++ tests/cli/test_restart_command.py | 81 ++++++++++++++++++++++++++++++- 4 files changed, 156 insertions(+), 6 deletions(-) create mode 100644 nanobot/config/runtime_keys.py diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index d611c277..b1e4f056 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -206,6 +206,57 @@ def _is_exit_command(command: str) -> bool: return command.lower() in EXIT_COMMANDS +def _parse_cli_session(session_id: str) -> tuple[str, str]: + """Split session id into (channel, chat_id).""" + if ":" in session_id: + return session_id.split(":", 1) + return "cli", session_id + + +def _should_show_cli_restart_notice( + restart_notify_channel: str, + restart_notify_chat_id: str, + session_id: str, +) -> bool: + """Return True when CLI should display restart-complete notice.""" + _, cli_chat_id = _parse_cli_session(session_id) + return restart_notify_channel == "cli" and ( + not restart_notify_chat_id or restart_notify_chat_id == cli_chat_id + ) + + +async def _notify_restart_done_when_channel_ready( + *, + bus, + channels, + channel: str, + chat_id: str, + timeout_s: float = 30.0, + poll_s: float = 0.25, +) -> bool: + """Wait for target channel readiness, then publish restart completion.""" + from nanobot.bus.events import OutboundMessage + + if not channel or not chat_id: + return False + if channel not in channels.enabled_channels: + return False + + waited = 0.0 + while waited <= timeout_s: + target = channels.get_channel(channel) + if target and target.is_running: + await bus.publish_outbound(OutboundMessage( + channel=channel, + chat_id=chat_id, + content="Restart completed.", + )) + return True + await asyncio.sleep(poll_s) + waited += poll_s + return False + + async def _read_interactive_input_async() -> str: """Read user input using prompt_toolkit (handles paste, history, display). @@ -598,6 +649,7 @@ def gateway( from nanobot.agent.loop import AgentLoop from nanobot.bus.queue import MessageBus from nanobot.channels.manager import ChannelManager + from nanobot.config.runtime_keys import RESTART_NOTIFY_CHANNEL_ENV, RESTART_NOTIFY_CHAT_ID_ENV from nanobot.cron.service import CronService from nanobot.cron.types import CronJob from nanobot.heartbeat.service import HeartbeatService @@ -696,6 +748,8 @@ def gateway( # Create channel manager channels = ChannelManager(config, bus) + restart_notify_channel = os.environ.pop(RESTART_NOTIFY_CHANNEL_ENV, "").strip() + restart_notify_chat_id = os.environ.pop(RESTART_NOTIFY_CHAT_ID_ENV, "").strip() def _pick_heartbeat_target() -> tuple[str, str]: """Pick a routable channel/chat target for heartbeat-triggered messages.""" @@ -772,6 +826,13 @@ def gateway( try: await cron.start() await heartbeat.start() + if restart_notify_channel and restart_notify_chat_id: + asyncio.create_task(_notify_restart_done_when_channel_ready( + bus=bus, + channels=channels, + channel=restart_notify_channel, + chat_id=restart_notify_chat_id, + )) await asyncio.gather( agent.run(), channels.start_all(), @@ -813,6 +874,7 @@ def agent( from nanobot.agent.loop import AgentLoop from nanobot.bus.queue import MessageBus + from nanobot.config.runtime_keys import RESTART_NOTIFY_CHANNEL_ENV, RESTART_NOTIFY_CHAT_ID_ENV from nanobot.cron.service import CronService config = _load_runtime_config(config, workspace) @@ -853,6 +915,13 @@ def agent( channels_config=config.channels, timezone=config.agents.defaults.timezone, ) + restart_notify_channel = os.environ.pop(RESTART_NOTIFY_CHANNEL_ENV, "").strip() + restart_notify_chat_id = os.environ.pop(RESTART_NOTIFY_CHAT_ID_ENV, "").strip() + + cli_channel, cli_chat_id = _parse_cli_session(session_id) + + if _should_show_cli_restart_notice(restart_notify_channel, restart_notify_chat_id, session_id): + _print_agent_response("Restart completed.", render_markdown=False) # Shared reference for progress callbacks _thinking: ThinkingSpinner | None = None @@ -891,11 +960,6 @@ def agent( _init_prompt_session() console.print(f"{__logo__} Interactive mode (type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit)\n") - if ":" in session_id: - cli_channel, cli_chat_id = session_id.split(":", 1) - else: - cli_channel, cli_chat_id = "cli", session_id - def _handle_signal(signum, frame): sig_name = signal.Signals(signum).name _restore_terminal() diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index 05d4fc16..f63a1e35 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -9,6 +9,7 @@ import sys from nanobot import __version__ from nanobot.bus.events import OutboundMessage from nanobot.command.router import CommandContext, CommandRouter +from nanobot.config.runtime_keys import RESTART_NOTIFY_CHANNEL_ENV, RESTART_NOTIFY_CHAT_ID_ENV from nanobot.utils.helpers import build_status_content @@ -35,6 +36,8 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage: async def cmd_restart(ctx: CommandContext) -> OutboundMessage: """Restart the process in-place via os.execv.""" msg = ctx.msg + os.environ[RESTART_NOTIFY_CHANNEL_ENV] = msg.channel + os.environ[RESTART_NOTIFY_CHAT_ID_ENV] = msg.chat_id async def _do_restart(): await asyncio.sleep(1) diff --git a/nanobot/config/runtime_keys.py b/nanobot/config/runtime_keys.py new file mode 100644 index 00000000..2dc6c923 --- /dev/null +++ b/nanobot/config/runtime_keys.py @@ -0,0 +1,4 @@ +"""Runtime environment variable keys shared across components.""" + +RESTART_NOTIFY_CHANNEL_ENV = "NANOBOT_RESTART_NOTIFY_CHANNEL" +RESTART_NOTIFY_CHAT_ID_ENV = "NANOBOT_RESTART_NOTIFY_CHAT_ID" diff --git a/tests/cli/test_restart_command.py b/tests/cli/test_restart_command.py index 6efcdad0..16b3aaa4 100644 --- a/tests/cli/test_restart_command.py +++ b/tests/cli/test_restart_command.py @@ -3,7 +3,9 @@ from __future__ import annotations import asyncio +import os import time +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -35,15 +37,19 @@ class TestRestartCommand: @pytest.mark.asyncio async def test_restart_sends_message_and_calls_execv(self): from nanobot.command.builtin import cmd_restart + from nanobot.config.runtime_keys import RESTART_NOTIFY_CHANNEL_ENV, RESTART_NOTIFY_CHAT_ID_ENV from nanobot.command.router import CommandContext loop, bus = _make_loop() msg = InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/restart") ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/restart", loop=loop) - with patch("nanobot.command.builtin.os.execv") as mock_execv: + with patch.dict(os.environ, {}, clear=False), \ + patch("nanobot.command.builtin.os.execv") as mock_execv: out = await cmd_restart(ctx) assert "Restarting" in out.content + assert os.environ.get(RESTART_NOTIFY_CHANNEL_ENV) == "cli" + assert os.environ.get(RESTART_NOTIFY_CHAT_ID_ENV) == "direct" await asyncio.sleep(1.5) mock_execv.assert_called_once() @@ -190,3 +196,76 @@ class TestRestartCommand: assert response is not None assert response.metadata == {"render_as": "text"} + + +@pytest.mark.asyncio +async def test_notify_restart_done_waits_until_channel_running() -> None: + from nanobot.bus.queue import MessageBus + from nanobot.cli.commands import _notify_restart_done_when_channel_ready + + bus = MessageBus() + channel = SimpleNamespace(is_running=False) + + class DummyChannels: + enabled_channels = ["feishu"] + + @staticmethod + def get_channel(name: str): + return channel if name == "feishu" else None + + async def _mark_running() -> None: + await asyncio.sleep(0.02) + channel.is_running = True + + marker = asyncio.create_task(_mark_running()) + sent = await _notify_restart_done_when_channel_ready( + bus=bus, + channels=DummyChannels(), + channel="feishu", + chat_id="oc_123", + timeout_s=0.2, + poll_s=0.01, + ) + await marker + + assert sent is True + out = await asyncio.wait_for(bus.consume_outbound(), timeout=0.1) + assert out.channel == "feishu" + assert out.chat_id == "oc_123" + assert out.content == "Restart completed." + + +@pytest.mark.asyncio +async def test_notify_restart_done_times_out_when_channel_not_running() -> None: + from nanobot.bus.queue import MessageBus + from nanobot.cli.commands import _notify_restart_done_when_channel_ready + + bus = MessageBus() + channel = SimpleNamespace(is_running=False) + + class DummyChannels: + enabled_channels = ["feishu"] + + @staticmethod + def get_channel(name: str): + return channel if name == "feishu" else None + + sent = await _notify_restart_done_when_channel_ready( + bus=bus, + channels=DummyChannels(), + channel="feishu", + chat_id="oc_123", + timeout_s=0.05, + poll_s=0.01, + ) + assert sent is False + assert bus.outbound_size == 0 + + +def test_should_show_cli_restart_notice() -> None: + from nanobot.cli.commands import _should_show_cli_restart_notice + + assert _should_show_cli_restart_notice("cli", "direct", "cli:direct") is True + assert _should_show_cli_restart_notice("cli", "", "cli:direct") is True + assert _should_show_cli_restart_notice("cli", "other", "cli:direct") is False + assert _should_show_cli_restart_notice("feishu", "oc_123", "cli:direct") is False From 896d5786775608ddd57eae3bf324cc6299ea4ccc Mon Sep 17 00:00:00 2001 From: imfondof Date: Fri, 3 Apr 2026 00:44:17 +0800 Subject: [PATCH 91/91] fix(restart): show restart completion with elapsed time across channels --- nanobot/channels/manager.py | 20 ++++++ nanobot/cli/commands.py | 85 +++++--------------------- nanobot/command/builtin.py | 5 +- nanobot/config/runtime_keys.py | 4 -- nanobot/utils/restart.py | 58 ++++++++++++++++++ tests/channels/test_channel_plugins.py | 28 +++++++++ tests/cli/test_restart_command.py | 81 ++---------------------- tests/utils/test_restart.py | 49 +++++++++++++++ 8 files changed, 179 insertions(+), 151 deletions(-) delete mode 100644 nanobot/config/runtime_keys.py create mode 100644 nanobot/utils/restart.py create mode 100644 tests/utils/test_restart.py diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 0d623225..1f26f4d7 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -11,6 +11,7 @@ from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.schema import Config +from nanobot.utils.restart import consume_restart_notice_from_env, format_restart_completed_message # Retry delays for message sending (exponential backoff: 1s, 2s, 4s) _SEND_RETRY_DELAYS = (1, 2, 4) @@ -91,9 +92,28 @@ class ChannelManager: logger.info("Starting {} channel...", name) tasks.append(asyncio.create_task(self._start_channel(name, channel))) + self._notify_restart_done_if_needed() + # Wait for all to complete (they should run forever) await asyncio.gather(*tasks, return_exceptions=True) + def _notify_restart_done_if_needed(self) -> None: + """Send restart completion message when runtime env markers are present.""" + notice = consume_restart_notice_from_env() + if not notice: + return + target = self.channels.get(notice.channel) + if not target: + return + asyncio.create_task(self._send_with_retry( + target, + OutboundMessage( + channel=notice.channel, + chat_id=notice.chat_id, + content=format_restart_completed_message(notice.started_at_raw), + ), + )) + async def stop_all(self) -> None: """Stop all channels and the dispatcher.""" logger.info("Stopping all channels...") diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index b1e4f056..4dcf3873 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -37,6 +37,11 @@ from nanobot.cli.stream import StreamRenderer, ThinkingSpinner from nanobot.config.paths import get_workspace_path, is_default_workspace from nanobot.config.schema import Config from nanobot.utils.helpers import sync_workspace_templates +from nanobot.utils.restart import ( + consume_restart_notice_from_env, + format_restart_completed_message, + should_show_cli_restart_notice, +) app = typer.Typer( name="nanobot", @@ -206,57 +211,6 @@ def _is_exit_command(command: str) -> bool: return command.lower() in EXIT_COMMANDS -def _parse_cli_session(session_id: str) -> tuple[str, str]: - """Split session id into (channel, chat_id).""" - if ":" in session_id: - return session_id.split(":", 1) - return "cli", session_id - - -def _should_show_cli_restart_notice( - restart_notify_channel: str, - restart_notify_chat_id: str, - session_id: str, -) -> bool: - """Return True when CLI should display restart-complete notice.""" - _, cli_chat_id = _parse_cli_session(session_id) - return restart_notify_channel == "cli" and ( - not restart_notify_chat_id or restart_notify_chat_id == cli_chat_id - ) - - -async def _notify_restart_done_when_channel_ready( - *, - bus, - channels, - channel: str, - chat_id: str, - timeout_s: float = 30.0, - poll_s: float = 0.25, -) -> bool: - """Wait for target channel readiness, then publish restart completion.""" - from nanobot.bus.events import OutboundMessage - - if not channel or not chat_id: - return False - if channel not in channels.enabled_channels: - return False - - waited = 0.0 - while waited <= timeout_s: - target = channels.get_channel(channel) - if target and target.is_running: - await bus.publish_outbound(OutboundMessage( - channel=channel, - chat_id=chat_id, - content="Restart completed.", - )) - return True - await asyncio.sleep(poll_s) - waited += poll_s - return False - - async def _read_interactive_input_async() -> str: """Read user input using prompt_toolkit (handles paste, history, display). @@ -649,7 +603,6 @@ def gateway( from nanobot.agent.loop import AgentLoop from nanobot.bus.queue import MessageBus from nanobot.channels.manager import ChannelManager - from nanobot.config.runtime_keys import RESTART_NOTIFY_CHANNEL_ENV, RESTART_NOTIFY_CHAT_ID_ENV from nanobot.cron.service import CronService from nanobot.cron.types import CronJob from nanobot.heartbeat.service import HeartbeatService @@ -748,8 +701,6 @@ def gateway( # Create channel manager channels = ChannelManager(config, bus) - restart_notify_channel = os.environ.pop(RESTART_NOTIFY_CHANNEL_ENV, "").strip() - restart_notify_chat_id = os.environ.pop(RESTART_NOTIFY_CHAT_ID_ENV, "").strip() def _pick_heartbeat_target() -> tuple[str, str]: """Pick a routable channel/chat target for heartbeat-triggered messages.""" @@ -826,13 +777,6 @@ def gateway( try: await cron.start() await heartbeat.start() - if restart_notify_channel and restart_notify_chat_id: - asyncio.create_task(_notify_restart_done_when_channel_ready( - bus=bus, - channels=channels, - channel=restart_notify_channel, - chat_id=restart_notify_chat_id, - )) await asyncio.gather( agent.run(), channels.start_all(), @@ -874,7 +818,6 @@ def agent( from nanobot.agent.loop import AgentLoop from nanobot.bus.queue import MessageBus - from nanobot.config.runtime_keys import RESTART_NOTIFY_CHANNEL_ENV, RESTART_NOTIFY_CHAT_ID_ENV from nanobot.cron.service import CronService config = _load_runtime_config(config, workspace) @@ -915,13 +858,12 @@ def agent( channels_config=config.channels, timezone=config.agents.defaults.timezone, ) - restart_notify_channel = os.environ.pop(RESTART_NOTIFY_CHANNEL_ENV, "").strip() - restart_notify_chat_id = os.environ.pop(RESTART_NOTIFY_CHAT_ID_ENV, "").strip() - - cli_channel, cli_chat_id = _parse_cli_session(session_id) - - if _should_show_cli_restart_notice(restart_notify_channel, restart_notify_chat_id, session_id): - _print_agent_response("Restart completed.", render_markdown=False) + restart_notice = consume_restart_notice_from_env() + if restart_notice and should_show_cli_restart_notice(restart_notice, session_id): + _print_agent_response( + format_restart_completed_message(restart_notice.started_at_raw), + render_markdown=False, + ) # Shared reference for progress callbacks _thinking: ThinkingSpinner | None = None @@ -960,6 +902,11 @@ def agent( _init_prompt_session() console.print(f"{__logo__} Interactive mode (type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit)\n") + if ":" in session_id: + cli_channel, cli_chat_id = session_id.split(":", 1) + else: + cli_channel, cli_chat_id = "cli", session_id + def _handle_signal(signum, frame): sig_name = signal.Signals(signum).name _restore_terminal() diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index f63a1e35..fa8dd693 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -9,8 +9,8 @@ import sys from nanobot import __version__ from nanobot.bus.events import OutboundMessage from nanobot.command.router import CommandContext, CommandRouter -from nanobot.config.runtime_keys import RESTART_NOTIFY_CHANNEL_ENV, RESTART_NOTIFY_CHAT_ID_ENV from nanobot.utils.helpers import build_status_content +from nanobot.utils.restart import set_restart_notice_to_env async def cmd_stop(ctx: CommandContext) -> OutboundMessage: @@ -36,8 +36,7 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage: async def cmd_restart(ctx: CommandContext) -> OutboundMessage: """Restart the process in-place via os.execv.""" msg = ctx.msg - os.environ[RESTART_NOTIFY_CHANNEL_ENV] = msg.channel - os.environ[RESTART_NOTIFY_CHAT_ID_ENV] = msg.chat_id + set_restart_notice_to_env(channel=msg.channel, chat_id=msg.chat_id) async def _do_restart(): await asyncio.sleep(1) diff --git a/nanobot/config/runtime_keys.py b/nanobot/config/runtime_keys.py deleted file mode 100644 index 2dc6c923..00000000 --- a/nanobot/config/runtime_keys.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Runtime environment variable keys shared across components.""" - -RESTART_NOTIFY_CHANNEL_ENV = "NANOBOT_RESTART_NOTIFY_CHANNEL" -RESTART_NOTIFY_CHAT_ID_ENV = "NANOBOT_RESTART_NOTIFY_CHAT_ID" diff --git a/nanobot/utils/restart.py b/nanobot/utils/restart.py new file mode 100644 index 00000000..35b8cced --- /dev/null +++ b/nanobot/utils/restart.py @@ -0,0 +1,58 @@ +"""Helpers for restart notification messages.""" + +from __future__ import annotations + +import os +import time +from dataclasses import dataclass + +RESTART_NOTIFY_CHANNEL_ENV = "NANOBOT_RESTART_NOTIFY_CHANNEL" +RESTART_NOTIFY_CHAT_ID_ENV = "NANOBOT_RESTART_NOTIFY_CHAT_ID" +RESTART_STARTED_AT_ENV = "NANOBOT_RESTART_STARTED_AT" + + +@dataclass(frozen=True) +class RestartNotice: + channel: str + chat_id: str + started_at_raw: str + + +def format_restart_completed_message(started_at_raw: str) -> str: + """Build restart completion text and include elapsed time when available.""" + elapsed_suffix = "" + if started_at_raw: + try: + elapsed_s = max(0.0, time.time() - float(started_at_raw)) + elapsed_suffix = f" in {elapsed_s:.1f}s" + except ValueError: + pass + return f"Restart completed{elapsed_suffix}." + + +def set_restart_notice_to_env(*, channel: str, chat_id: str) -> None: + """Write restart notice env values for the next process.""" + os.environ[RESTART_NOTIFY_CHANNEL_ENV] = channel + os.environ[RESTART_NOTIFY_CHAT_ID_ENV] = chat_id + os.environ[RESTART_STARTED_AT_ENV] = str(time.time()) + + +def consume_restart_notice_from_env() -> RestartNotice | None: + """Read and clear restart notice env values once for this process.""" + channel = os.environ.pop(RESTART_NOTIFY_CHANNEL_ENV, "").strip() + chat_id = os.environ.pop(RESTART_NOTIFY_CHAT_ID_ENV, "").strip() + started_at_raw = os.environ.pop(RESTART_STARTED_AT_ENV, "").strip() + if not (channel and chat_id): + return None + return RestartNotice(channel=channel, chat_id=chat_id, started_at_raw=started_at_raw) + + +def should_show_cli_restart_notice(notice: RestartNotice, session_id: str) -> bool: + """Return True when a restart notice should be shown in this CLI session.""" + if notice.channel != "cli": + return False + if ":" in session_id: + _, cli_chat_id = session_id.split(":", 1) + else: + cli_chat_id = session_id + return not notice.chat_id or notice.chat_id == cli_chat_id diff --git a/tests/channels/test_channel_plugins.py b/tests/channels/test_channel_plugins.py index 4cf4fab2..8bb95b53 100644 --- a/tests/channels/test_channel_plugins.py +++ b/tests/channels/test_channel_plugins.py @@ -13,6 +13,7 @@ from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.channels.manager import ChannelManager from nanobot.config.schema import ChannelsConfig +from nanobot.utils.restart import RestartNotice # --------------------------------------------------------------------------- @@ -929,3 +930,30 @@ async def test_start_all_creates_dispatch_task(): # Dispatch task should have been created assert mgr._dispatch_task is not None + +@pytest.mark.asyncio +async def test_notify_restart_done_enqueues_outbound_message(): + """Restart notice should schedule send_with_retry for target channel.""" + fake_config = SimpleNamespace( + channels=ChannelsConfig(), + providers=SimpleNamespace(groq=SimpleNamespace(api_key="")), + ) + + mgr = ChannelManager.__new__(ChannelManager) + mgr.config = fake_config + mgr.bus = MessageBus() + mgr.channels = {"feishu": _StartableChannel(fake_config, mgr.bus)} + mgr._dispatch_task = None + mgr._send_with_retry = AsyncMock() + + notice = RestartNotice(channel="feishu", chat_id="oc_123", started_at_raw="100.0") + with patch("nanobot.channels.manager.consume_restart_notice_from_env", return_value=notice): + mgr._notify_restart_done_if_needed() + + await asyncio.sleep(0) + mgr._send_with_retry.assert_awaited_once() + sent_channel, sent_msg = mgr._send_with_retry.await_args.args + assert sent_channel is mgr.channels["feishu"] + assert sent_msg.channel == "feishu" + assert sent_msg.chat_id == "oc_123" + assert sent_msg.content.startswith("Restart completed") diff --git a/tests/cli/test_restart_command.py b/tests/cli/test_restart_command.py index 16b3aaa4..8ea30f68 100644 --- a/tests/cli/test_restart_command.py +++ b/tests/cli/test_restart_command.py @@ -5,7 +5,6 @@ from __future__ import annotations import asyncio import os import time -from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -37,8 +36,12 @@ class TestRestartCommand: @pytest.mark.asyncio async def test_restart_sends_message_and_calls_execv(self): from nanobot.command.builtin import cmd_restart - from nanobot.config.runtime_keys import RESTART_NOTIFY_CHANNEL_ENV, RESTART_NOTIFY_CHAT_ID_ENV from nanobot.command.router import CommandContext + from nanobot.utils.restart import ( + RESTART_NOTIFY_CHANNEL_ENV, + RESTART_NOTIFY_CHAT_ID_ENV, + RESTART_STARTED_AT_ENV, + ) loop, bus = _make_loop() msg = InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/restart") @@ -50,6 +53,7 @@ class TestRestartCommand: assert "Restarting" in out.content assert os.environ.get(RESTART_NOTIFY_CHANNEL_ENV) == "cli" assert os.environ.get(RESTART_NOTIFY_CHAT_ID_ENV) == "direct" + assert os.environ.get(RESTART_STARTED_AT_ENV) await asyncio.sleep(1.5) mock_execv.assert_called_once() @@ -196,76 +200,3 @@ class TestRestartCommand: assert response is not None assert response.metadata == {"render_as": "text"} - - -@pytest.mark.asyncio -async def test_notify_restart_done_waits_until_channel_running() -> None: - from nanobot.bus.queue import MessageBus - from nanobot.cli.commands import _notify_restart_done_when_channel_ready - - bus = MessageBus() - channel = SimpleNamespace(is_running=False) - - class DummyChannels: - enabled_channels = ["feishu"] - - @staticmethod - def get_channel(name: str): - return channel if name == "feishu" else None - - async def _mark_running() -> None: - await asyncio.sleep(0.02) - channel.is_running = True - - marker = asyncio.create_task(_mark_running()) - sent = await _notify_restart_done_when_channel_ready( - bus=bus, - channels=DummyChannels(), - channel="feishu", - chat_id="oc_123", - timeout_s=0.2, - poll_s=0.01, - ) - await marker - - assert sent is True - out = await asyncio.wait_for(bus.consume_outbound(), timeout=0.1) - assert out.channel == "feishu" - assert out.chat_id == "oc_123" - assert out.content == "Restart completed." - - -@pytest.mark.asyncio -async def test_notify_restart_done_times_out_when_channel_not_running() -> None: - from nanobot.bus.queue import MessageBus - from nanobot.cli.commands import _notify_restart_done_when_channel_ready - - bus = MessageBus() - channel = SimpleNamespace(is_running=False) - - class DummyChannels: - enabled_channels = ["feishu"] - - @staticmethod - def get_channel(name: str): - return channel if name == "feishu" else None - - sent = await _notify_restart_done_when_channel_ready( - bus=bus, - channels=DummyChannels(), - channel="feishu", - chat_id="oc_123", - timeout_s=0.05, - poll_s=0.01, - ) - assert sent is False - assert bus.outbound_size == 0 - - -def test_should_show_cli_restart_notice() -> None: - from nanobot.cli.commands import _should_show_cli_restart_notice - - assert _should_show_cli_restart_notice("cli", "direct", "cli:direct") is True - assert _should_show_cli_restart_notice("cli", "", "cli:direct") is True - assert _should_show_cli_restart_notice("cli", "other", "cli:direct") is False - assert _should_show_cli_restart_notice("feishu", "oc_123", "cli:direct") is False diff --git a/tests/utils/test_restart.py b/tests/utils/test_restart.py new file mode 100644 index 00000000..48124d38 --- /dev/null +++ b/tests/utils/test_restart.py @@ -0,0 +1,49 @@ +"""Tests for restart notice helpers.""" + +from __future__ import annotations + +import os + +from nanobot.utils.restart import ( + RestartNotice, + consume_restart_notice_from_env, + format_restart_completed_message, + set_restart_notice_to_env, + should_show_cli_restart_notice, +) + + +def test_set_and_consume_restart_notice_env_roundtrip(monkeypatch): + monkeypatch.delenv("NANOBOT_RESTART_NOTIFY_CHANNEL", raising=False) + monkeypatch.delenv("NANOBOT_RESTART_NOTIFY_CHAT_ID", raising=False) + monkeypatch.delenv("NANOBOT_RESTART_STARTED_AT", raising=False) + + set_restart_notice_to_env(channel="feishu", chat_id="oc_123") + + notice = consume_restart_notice_from_env() + assert notice is not None + assert notice.channel == "feishu" + assert notice.chat_id == "oc_123" + assert notice.started_at_raw + + # Consumed values should be cleared from env. + assert consume_restart_notice_from_env() is None + assert "NANOBOT_RESTART_NOTIFY_CHANNEL" not in os.environ + assert "NANOBOT_RESTART_NOTIFY_CHAT_ID" not in os.environ + assert "NANOBOT_RESTART_STARTED_AT" not in os.environ + + +def test_format_restart_completed_message_with_elapsed(monkeypatch): + monkeypatch.setattr("nanobot.utils.restart.time.time", lambda: 102.0) + assert format_restart_completed_message("100.0") == "Restart completed in 2.0s." + + +def test_should_show_cli_restart_notice(): + notice = RestartNotice(channel="cli", chat_id="direct", started_at_raw="100") + assert should_show_cli_restart_notice(notice, "cli:direct") is True + assert should_show_cli_restart_notice(notice, "cli:other") is False + assert should_show_cli_restart_notice(notice, "direct") is True + + non_cli = RestartNotice(channel="feishu", chat_id="oc_1", started_at_raw="100") + assert should_show_cli_restart_notice(non_cli, "cli:direct") is False +