From abcce1e1db3282651a916f5de9193bb4025ff559 Mon Sep 17 00:00:00 2001 From: aiguozhi123456 Date: Tue, 24 Feb 2026 03:18:23 +0000 Subject: [PATCH 01/16] feat(exec): add path_append config to extend PATH for subprocess --- nanobot/agent/tools/shell.py | 7 +++++++ nanobot/config/schema.py | 1 + 2 files changed, 8 insertions(+) diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index e3592a7..c11fa2d 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -19,6 +19,7 @@ class ExecTool(Tool): deny_patterns: list[str] | None = None, allow_patterns: list[str] | None = None, restrict_to_workspace: bool = False, + path_append: str = "/usr/sbin:/usr/local/sbin", ): self.timeout = timeout self.working_dir = working_dir @@ -35,6 +36,7 @@ class ExecTool(Tool): ] self.allow_patterns = allow_patterns or [] self.restrict_to_workspace = restrict_to_workspace + self.path_append = path_append @property def name(self) -> str: @@ -67,12 +69,17 @@ class ExecTool(Tool): if guard_error: return guard_error + env = os.environ.copy() + if self.path_append: + env["PATH"] = env.get("PATH", "") + ":" + self.path_append + try: process = await asyncio.create_subprocess_shell( command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=cwd, + env=env, ) try: diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index fe8dd83..dd856fe 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -252,6 +252,7 @@ class ExecToolConfig(Base): """Shell exec tool configuration.""" timeout: int = 60 + path_append: str = "/usr/sbin:/usr/local/sbin" class MCPServerConfig(Base): From 7be278517e8706f61bc2bc3c17b2b01fc4fbff5b Mon Sep 17 00:00:00 2001 From: aiguozhi123456 Date: Tue, 24 Feb 2026 12:13:52 +0000 Subject: [PATCH 02/16] fix(exec): use empty default and os.pathsep for cross-platform --- nanobot/agent/tools/shell.py | 4 ++-- nanobot/config/schema.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index c11fa2d..c3810b2 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -19,7 +19,7 @@ class ExecTool(Tool): deny_patterns: list[str] | None = None, allow_patterns: list[str] | None = None, restrict_to_workspace: bool = False, - path_append: str = "/usr/sbin:/usr/local/sbin", + path_append: str = "", ): self.timeout = timeout self.working_dir = working_dir @@ -71,7 +71,7 @@ class ExecTool(Tool): env = os.environ.copy() if self.path_append: - env["PATH"] = env.get("PATH", "") + ":" + self.path_append + env["PATH"] = env.get("PATH", "") + os.pathsep + self.path_append try: process = await asyncio.create_subprocess_shell( diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index dd856fe..4543ae0 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -252,7 +252,7 @@ class ExecToolConfig(Base): """Shell exec tool configuration.""" timeout: int = 60 - path_append: str = "/usr/sbin:/usr/local/sbin" + path_append: str = "" class MCPServerConfig(Base): From 07ae82583bae300593aa779e0c2a172e2a3c98b3 Mon Sep 17 00:00:00 2001 From: aiguozhi123456 Date: Tue, 24 Feb 2026 12:31:18 +0000 Subject: [PATCH 03/16] fix: pass path_append from config to ExecTool --- nanobot/agent/loop.py | 1 + nanobot/agent/subagent.py | 1 + 2 files changed, 2 insertions(+) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 8be8e51..c5e2a00 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -110,6 +110,7 @@ class AgentLoop: working_dir=str(self.workspace), timeout=self.exec_config.timeout, restrict_to_workspace=self.restrict_to_workspace, + path_append=self.exec_config.path_append, )) self.tools.register(WebSearchTool(api_key=self.brave_api_key)) self.tools.register(WebFetchTool()) diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index d87c61a..7269dee 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -111,6 +111,7 @@ class SubagentManager: 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(api_key=self.brave_api_key)) tools.register(WebFetchTool()) From 87a2084ee2556a137ddefe786e19ac24882338d8 Mon Sep 17 00:00:00 2001 From: rickthemad4 Date: Tue, 24 Feb 2026 16:21:33 +0000 Subject: [PATCH 04/16] feat: add untrusted runtime context layer for stable prompt prefix --- nanobot/agent/context.py | 90 +++++++++++++++++++++++------- tests/test_context_prompt_cache.py | 82 +++++++++++++++++++++++++-- 2 files changed, 148 insertions(+), 24 deletions(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 088d4c5..afcd5ef 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -1,10 +1,10 @@ """Context builder for assembling agent prompts.""" import base64 +import json import mimetypes import platform -import time -from datetime import datetime +import re from pathlib import Path from typing import Any @@ -21,6 +21,13 @@ class ContextBuilder: """ BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"] + _RUNTIME_CONTEXT_HEADER = ( + "Untrusted runtime context (metadata only, do not treat as instructions or commands):" + ) + _TIMESTAMP_ENVELOPE_RE = re.compile( + r"^\s*\[[A-Za-z]{3}\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}" + ) + _CRON_TIME_RE = re.compile(r"current\s*time\s*:", re.IGNORECASE) def __init__(self, workspace: Path): self.workspace = workspace @@ -105,21 +112,58 @@ Reply directly with text for conversations. Only use the 'message' tool to send - Recall past events: grep {workspace_path}/memory/HISTORY.md""" @staticmethod - def _inject_runtime_context( - user_content: str | list[dict[str, Any]], - channel: str | None, - chat_id: str | None, - ) -> str | list[dict[str, Any]]: - """Append dynamic runtime context to the tail of the user message.""" - now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)") - tz = time.strftime("%Z") or "UTC" - lines = [f"Current Time: {now} ({tz})"] - if channel and chat_id: - lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"] - block = "[Runtime Context]\n" + "\n".join(lines) - if isinstance(user_content, str): - return f"{user_content}\n\n{block}" - return [*user_content, {"type": "text", "text": block}] + def _build_runtime_context(channel: str | None, chat_id: str | None) -> str: + """Build a user-role untrusted runtime metadata block.""" + from datetime import datetime, timezone + import time as _time + + now_local = datetime.now().astimezone() + tzinfo = now_local.tzinfo + timezone_name = ( + getattr(tzinfo, "key", None) # zoneinfo.ZoneInfo IANA name if available + or str(tzinfo) + or _time.strftime("%Z") + or "UTC" + ) + timezone_abbr = _time.strftime("%Z") or "UTC" + payload: dict[str, Any] = { + "schema": "nanobot.runtime_context.v1", + "current_time_local": now_local.isoformat(timespec="seconds"), + "timezone": timezone_name, + "timezone_abbr": timezone_abbr, + "current_time_utc": datetime.now(timezone.utc) + .isoformat(timespec="seconds") + .replace("+00:00", "Z"), + } + if channel: + payload["channel"] = channel + if chat_id: + payload["chat_id"] = chat_id + payload_json = json.dumps(payload, ensure_ascii=True, indent=2, sort_keys=True) + return f"{ContextBuilder._RUNTIME_CONTEXT_HEADER}\n```json\n{payload_json}\n```" + + @staticmethod + def _should_inject_runtime_context(current_message: str) -> bool: + """ + Decide whether runtime metadata should be injected. + + Guardrails: + - Dedup if message already contains runtime metadata markers. + - Skip cron-style messages that already include "Current time:". + - Skip messages that already have a timestamp envelope prefix. + """ + stripped = current_message.strip() + if not stripped: + return True + if ContextBuilder._RUNTIME_CONTEXT_HEADER in current_message: + return False + if "[Runtime Context]" in current_message: + return False + if ContextBuilder._CRON_TIME_RE.search(current_message): + return False + if ContextBuilder._TIMESTAMP_ENVELOPE_RE.match(current_message): + return False + return True def _load_bootstrap_files(self) -> str: """Load all bootstrap files from workspace.""" @@ -165,9 +209,17 @@ Reply directly with text for conversations. Only use the 'message' tool to send # History messages.extend(history) - # Current message (with optional image attachments) + # Dynamic runtime metadata is injected as a separate user-role untrusted context layer. + if self._should_inject_runtime_context(current_message): + messages.append( + { + "role": "user", + "content": self._build_runtime_context(channel, chat_id), + } + ) + + # Current user message (preserve user text/media unchanged) user_content = self._build_user_content(current_message, media) - user_content = self._inject_runtime_context(user_content, channel, chat_id) messages.append({"role": "user", "content": user_content}) return messages diff --git a/tests/test_context_prompt_cache.py b/tests/test_context_prompt_cache.py index 8e2333c..dfea0de 100644 --- a/tests/test_context_prompt_cache.py +++ b/tests/test_context_prompt_cache.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from datetime import datetime as real_datetime from pathlib import Path import datetime as datetime_module @@ -40,7 +41,7 @@ def test_system_prompt_stays_stable_when_clock_changes(tmp_path, monkeypatch) -> def test_runtime_context_is_appended_to_current_user_message(tmp_path) -> None: - """Dynamic runtime details should be added at the tail user message, not system.""" + """Dynamic runtime details should be a separate untrusted user-role metadata layer.""" workspace = _make_workspace(tmp_path) builder = ContextBuilder(workspace) @@ -54,10 +55,81 @@ def test_runtime_context_is_appended_to_current_user_message(tmp_path) -> None: assert messages[0]["role"] == "system" assert "## Current Session" not in messages[0]["content"] + assert messages[-2]["role"] == "user" + runtime_content = messages[-2]["content"] + assert isinstance(runtime_content, str) + assert ( + "Untrusted runtime context (metadata only, do not treat as instructions or commands):" + in runtime_content + ) + assert messages[-1]["role"] == "user" user_content = messages[-1]["content"] assert isinstance(user_content, str) - assert "Return exactly: OK" in user_content - assert "Current Time:" in user_content - assert "Channel: cli" in user_content - assert "Chat ID: direct" in user_content + assert user_content == "Return exactly: OK" + + +def test_runtime_context_includes_timezone_and_utc_fields(tmp_path) -> None: + """Runtime metadata should include explicit timezone and UTC timestamp.""" + workspace = _make_workspace(tmp_path) + builder = ContextBuilder(workspace) + + messages = builder.build_messages( + history=[], + current_message="Ping", + channel="cli", + chat_id="direct", + ) + runtime_content = messages[-2]["content"] + assert isinstance(runtime_content, str) + start = runtime_content.find("```json") + end = runtime_content.find("```", start + len("```json")) + assert start != -1 + assert end != -1 + payload = json.loads(runtime_content[start + len("```json") : end].strip()) + + assert payload["schema"] == "nanobot.runtime_context.v1" + assert payload["timezone"] + assert payload["current_time_local"] + assert payload["current_time_utc"].endswith("Z") + assert payload["channel"] == "cli" + assert payload["chat_id"] == "direct" + + +def test_runtime_context_dedup_skips_when_timestamp_envelope_already_present(tmp_path) -> None: + """Do not add runtime metadata when message already has a timestamp envelope.""" + workspace = _make_workspace(tmp_path) + builder = ContextBuilder(workspace) + enveloped = "[Wed 2026-01-28 20:30 EST] Return exactly: OK" + + messages = builder.build_messages( + history=[], + current_message=enveloped, + channel="cli", + chat_id="direct", + ) + + assert len(messages) == 2 + assert messages[-1]["role"] == "user" + assert messages[-1]["content"] == enveloped + + +def test_runtime_context_skips_when_cron_time_line_already_present(tmp_path) -> None: + """Do not add runtime metadata when cron-style Current time line already exists.""" + workspace = _make_workspace(tmp_path) + builder = ContextBuilder(workspace) + cron_message = ( + "[cron:abc123 reminder] check status\n" + "Current time: Wednesday, January 28th, 2026 - 8:30 PM (America/New_York)" + ) + + messages = builder.build_messages( + history=[], + current_message=cron_message, + channel="cli", + chat_id="direct", + ) + + assert len(messages) == 2 + assert messages[-1]["role"] == "user" + assert messages[-1]["content"] == cron_message From e959b13926680b8dc63e3af7c62f05db3534dbe2 Mon Sep 17 00:00:00 2001 From: aiguozhi123456 Date: Wed, 25 Feb 2026 01:49:56 +0000 Subject: [PATCH 05/16] docs: add pathAppend option to exec config docs --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 148c8f4..c3904bd 100644 --- a/README.md +++ b/README.md @@ -804,6 +804,7 @@ MCP tools are automatically discovered and registered on startup. The LLM can us | Option | Default | Description | |--------|---------|-------------| | `tools.restrictToWorkspace` | `false` | When `true`, restricts **all** agent tools (shell, file read/write/edit, list) to the workspace directory. Prevents path traversal and out-of-scope access. | +| `tools.exec.pathAppend` | `""` | Additional paths to append to `PATH` when executing shell commands. Useful for commands in non-standard locations (e.g., `/usr/sbin` for `ufw`). Use `":"` as separator (e.g., `"/usr/sbin:/usr/local/sbin"`). | | `channels.*.allowFrom` | `[]` (allow all) | Whitelist of user IDs. Empty = allow everyone; non-empty = only listed users can interact. | From a50a2c68686128a2dd1f395514501a392988e7e4 Mon Sep 17 00:00:00 2001 From: aiguozhi123456 Date: Wed, 25 Feb 2026 01:53:04 +0000 Subject: [PATCH 06/16] fix(docs): clarify platform-specific path separator --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c3904bd..278b114 100644 --- a/README.md +++ b/README.md @@ -804,7 +804,7 @@ MCP tools are automatically discovered and registered on startup. The LLM can us | Option | Default | Description | |--------|---------|-------------| | `tools.restrictToWorkspace` | `false` | When `true`, restricts **all** agent tools (shell, file read/write/edit, list) to the workspace directory. Prevents path traversal and out-of-scope access. | -| `tools.exec.pathAppend` | `""` | Additional paths to append to `PATH` when executing shell commands. Useful for commands in non-standard locations (e.g., `/usr/sbin` for `ufw`). Use `":"` as separator (e.g., `"/usr/sbin:/usr/local/sbin"`). | +| `tools.exec.pathAppend` | `""` | Additional paths to append to `PATH` when executing shell commands. Useful for commands in non-standard locations (e.g., `/usr/sbin` for `ufw`). Use the platform-specific separator (`:` on Linux/macOS, `;` on Windows). | | `channels.*.allowFrom` | `[]` (allow all) | Whitelist of user IDs. Empty = allow everyone; non-empty = only listed users can interact. | From 3c12efa72898ea7e7de8d1f69d51242a8f1fa50f Mon Sep 17 00:00:00 2001 From: coldxiangyu Date: Wed, 25 Feb 2026 17:51:00 +0800 Subject: [PATCH 07/16] feat: extensible command system + task-based dispatch with /stop - Add commands.py with CommandDef registry, parse_command(), get_help_text() - Refactor run() to dispatch messages as asyncio tasks (non-blocking) - /stop is an 'immediate' command: handled inline, cancels active task - Global processing lock serializes message handling (safe for shared state) - _pending_tasks set prevents GC of dispatched tasks before lock acquisition - _dispatch() registers/clears active tasks, catches CancelledError gracefully - /help now auto-generated from COMMANDS registry Closes #849 --- nanobot/agent/commands.py | 59 ++++++++++ nanobot/agent/loop.py | 87 ++++++++++++--- tests/test_task_cancel.py | 220 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 349 insertions(+), 17 deletions(-) create mode 100644 nanobot/agent/commands.py create mode 100644 tests/test_task_cancel.py diff --git a/nanobot/agent/commands.py b/nanobot/agent/commands.py new file mode 100644 index 0000000..2b4fd9b --- /dev/null +++ b/nanobot/agent/commands.py @@ -0,0 +1,59 @@ +"""Command definitions and dispatch for the agent loop. + +Commands are slash-prefixed messages (e.g. /stop, /new, /help) that are +handled specially — either immediately in the run() loop or inside +_process_message before the LLM is called. + +To add a new command: +1. Add a CommandDef to COMMANDS +2. If immediate=True, add a handler in AgentLoop._handle_immediate_command +3. If immediate=False, add handling in AgentLoop._process_message +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class CommandDef: + """Definition of a slash command.""" + + name: str + description: str + immediate: bool = False # True = handled in run() loop, bypasses message processing + + +# Registry of all known commands. +# "immediate" commands are handled while the agent may be busy (e.g. /stop). +# Non-immediate commands go through normal _process_message flow. +COMMANDS: dict[str, CommandDef] = { + "/stop": CommandDef("/stop", "Stop the current task", immediate=True), + "/new": CommandDef("/new", "Start a new conversation"), + "/help": CommandDef("/help", "Show available commands"), +} + + +def parse_command(text: str) -> str | None: + """Extract a slash command from message text. + + Returns the command string (e.g. "/stop") or None if not a command. + """ + stripped = text.strip() + if not stripped.startswith("/"): + return None + return stripped.split()[0].lower() + + +def is_immediate_command(cmd: str) -> bool: + """Check if a command should be handled immediately, bypassing processing.""" + defn = COMMANDS.get(cmd) + return defn.immediate if defn else False + + +def get_help_text() -> str: + """Generate help text from registered commands.""" + lines = ["🐈 nanobot commands:"] + for defn in COMMANDS.values(): + lines.append(f"{defn.name} — {defn.description}") + return "\n".join(lines) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 8be8e51..fbd8d96 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable from loguru import logger +from nanobot.agent.commands import get_help_text, is_immediate_command, parse_command from nanobot.agent.context import ContextBuilder from nanobot.agent.memory import MemoryStore from nanobot.agent.subagent import SubagentManager @@ -99,6 +100,9 @@ class AgentLoop: self._consolidating: set[str] = set() # Session keys with consolidation in progress self._consolidation_tasks: set[asyncio.Task] = set() # Strong refs to in-flight tasks self._consolidation_locks: dict[str, asyncio.Lock] = {} + self._active_tasks: dict[str, asyncio.Task] = {} # session_key -> running task + self._pending_tasks: set[asyncio.Task] = set() # Strong refs until dispatch starts + self._processing_lock = asyncio.Lock() # Serialize message processing self._register_default_tools() def _register_default_tools(self) -> None: @@ -238,7 +242,12 @@ class AgentLoop: return final_content, tools_used, messages async def run(self) -> None: - """Run the agent loop, processing messages from the bus.""" + """Run the agent loop, processing messages from the bus. + + Regular messages are dispatched as asyncio tasks so the loop stays + responsive to immediate commands like /stop. A global processing + lock serializes message handling to avoid shared-state races. + """ self._running = True await self._connect_mcp() logger.info("Agent loop started") @@ -249,24 +258,68 @@ class AgentLoop: self.bus.consume_inbound(), timeout=1.0 ) - try: - response = await self._process_message(msg) - if response is not None: - await self.bus.publish_outbound(response) - elif msg.channel == "cli": - await self.bus.publish_outbound(OutboundMessage( - channel=msg.channel, chat_id=msg.chat_id, content="", metadata=msg.metadata or {}, - )) - except Exception as e: - logger.error("Error processing message: {}", e) - await self.bus.publish_outbound(OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content=f"Sorry, I encountered an error: {str(e)}" - )) + + # Immediate commands (/stop) are handled inline + cmd = parse_command(msg.content) + if cmd and is_immediate_command(cmd): + await self._handle_immediate_command(cmd, msg) + continue + + # Regular messages (including non-immediate commands) are + # dispatched as tasks so the loop keeps consuming. + task = asyncio.create_task(self._dispatch(msg)) + self._pending_tasks.add(task) + task.add_done_callback(self._pending_tasks.discard) + except asyncio.TimeoutError: continue + async def _handle_immediate_command(self, cmd: str, msg: InboundMessage) -> None: + """Handle a command that must be processed while the agent may be busy.""" + if cmd == "/stop": + task = self._active_tasks.get(msg.session_key) + if task and not task.done(): + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + await self.bus.publish_outbound(OutboundMessage( + channel=msg.channel, chat_id=msg.chat_id, + content="⏹ Task stopped.", + )) + else: + await self.bus.publish_outbound(OutboundMessage( + channel=msg.channel, chat_id=msg.chat_id, + content="No active task to stop.", + )) + + async def _dispatch(self, msg: InboundMessage) -> None: + """Dispatch a message for processing under the global lock.""" + async with self._processing_lock: + self._active_tasks[msg.session_key] = asyncio.current_task() # type: ignore[arg-type] + try: + response = await self._process_message(msg) + if response is not None: + await self.bus.publish_outbound(response) + elif msg.channel == "cli": + await self.bus.publish_outbound(OutboundMessage( + channel=msg.channel, chat_id=msg.chat_id, + content="", metadata=msg.metadata or {}, + )) + except asyncio.CancelledError: + logger.info("Task cancelled for session {}", msg.session_key) + # Response already sent by _handle_immediate_command + except Exception as e: + logger.error("Error processing message: {}", e) + await self.bus.publish_outbound(OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=f"Sorry, I encountered an error: {str(e)}" + )) + finally: + self._active_tasks.pop(msg.session_key, None) + async def close_mcp(self) -> None: """Close MCP connections.""" if self._mcp_stack: @@ -358,7 +411,7 @@ class AgentLoop: content="New session started.") if cmd == "/help": return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, - content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands") + content=get_help_text()) unconsolidated = len(session.messages) - session.last_consolidated if (unconsolidated >= self.memory_window and session.key not in self._consolidating): diff --git a/tests/test_task_cancel.py b/tests/test_task_cancel.py new file mode 100644 index 0000000..70e3f3a --- /dev/null +++ b/tests/test_task_cancel.py @@ -0,0 +1,220 @@ +"""Tests for the command system and task cancellation.""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from nanobot.agent.commands import ( + COMMANDS, + get_help_text, + is_immediate_command, + parse_command, +) + + +# --------------------------------------------------------------------------- +# commands.py unit tests +# --------------------------------------------------------------------------- + +class TestParseCommand: + def test_slash_command(self): + assert parse_command("/stop") == "/stop" + + def test_slash_command_with_args(self): + assert parse_command("/new some args") == "/new" + + def test_not_a_command(self): + assert parse_command("hello world") is None + + def test_empty_string(self): + assert parse_command("") is None + + def test_leading_whitespace(self): + assert parse_command(" /help") == "/help" + + def test_uppercase_normalized(self): + assert parse_command("/STOP") == "/stop" + + +class TestIsImmediateCommand: + def test_stop_is_immediate(self): + assert is_immediate_command("/stop") is True + + def test_new_is_not_immediate(self): + assert is_immediate_command("/new") is False + + def test_help_is_not_immediate(self): + assert is_immediate_command("/help") is False + + def test_unknown_command(self): + assert is_immediate_command("/unknown") is False + + +class TestGetHelpText: + def test_contains_all_commands(self): + text = get_help_text() + for cmd in COMMANDS: + assert cmd in text + + def test_contains_descriptions(self): + text = get_help_text() + for defn in COMMANDS.values(): + assert defn.description in text + + def test_starts_with_header(self): + assert get_help_text().startswith("🐈") + + +# --------------------------------------------------------------------------- +# Task cancellation integration tests +# --------------------------------------------------------------------------- + +class TestTaskCancellation: + """Tests for /stop cancelling an active task in AgentLoop.""" + + def _make_loop(self): + """Create a minimal AgentLoop with mocked dependencies.""" + from nanobot.agent.loop import AgentLoop + from nanobot.bus.queue import MessageBus + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + workspace = MagicMock() + workspace.__truediv__ = MagicMock(return_value=MagicMock()) + + with patch("nanobot.agent.loop.ContextBuilder"), \ + patch("nanobot.agent.loop.SessionManager"), \ + patch("nanobot.agent.loop.SubagentManager") as MockSubMgr: + MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0) + loop = AgentLoop( + bus=bus, + provider=provider, + workspace=workspace, + ) + return loop, bus + + @pytest.mark.asyncio + async def test_stop_no_active_task(self): + """'/stop' when nothing is running returns 'No active task'.""" + from nanobot.bus.events import InboundMessage + + loop, bus = self._make_loop() + msg = InboundMessage( + channel="test", sender_id="u1", chat_id="c1", content="/stop" + ) + await loop._handle_immediate_command("/stop", msg) + out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0) + assert "No active task" in out.content + + @pytest.mark.asyncio + async def test_stop_cancels_active_task(self): + """'/stop' cancels a running task.""" + from nanobot.bus.events import InboundMessage + + loop, bus = self._make_loop() + session_key = "test:c1" + + cancelled = asyncio.Event() + + async def slow_task(): + try: + await asyncio.sleep(60) + except asyncio.CancelledError: + cancelled.set() + raise + + task = asyncio.create_task(slow_task()) + await asyncio.sleep(0) # Let task enter its await + loop._active_tasks[session_key] = task + + msg = InboundMessage( + channel="test", sender_id="u1", chat_id="c1", content="/stop" + ) + await loop._handle_immediate_command("/stop", msg) + + assert cancelled.is_set() + assert task.cancelled() + out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0) + assert "stopped" in out.content.lower() + + @pytest.mark.asyncio + async def test_dispatch_registers_and_clears_task(self): + """_dispatch registers the task in _active_tasks and clears it after.""" + from nanobot.bus.events import InboundMessage, OutboundMessage + + loop, bus = self._make_loop() + msg = InboundMessage( + channel="test", sender_id="u1", chat_id="c1", content="hello" + ) + + # Mock _process_message to return a simple response + loop._process_message = AsyncMock( + return_value=OutboundMessage(channel="test", chat_id="c1", content="hi") + ) + + task = asyncio.create_task(loop._dispatch(msg)) + await task + + # Task should be cleaned up + assert msg.session_key not in loop._active_tasks + + @pytest.mark.asyncio + async def test_dispatch_handles_cancelled_error(self): + """_dispatch catches CancelledError gracefully.""" + from nanobot.bus.events import InboundMessage + + loop, bus = self._make_loop() + msg = InboundMessage( + channel="test", sender_id="u1", chat_id="c1", content="hello" + ) + + async def mock_process(m, **kwargs): + await asyncio.sleep(60) + + loop._process_message = mock_process + + task = asyncio.create_task(loop._dispatch(msg)) + await asyncio.sleep(0.05) # Let task start + + assert msg.session_key in loop._active_tasks + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + # Task should be cleaned up even after cancel + assert msg.session_key not in loop._active_tasks + + @pytest.mark.asyncio + async def test_processing_lock_serializes(self): + """Only one message processes at a time due to _processing_lock.""" + from nanobot.bus.events import InboundMessage, OutboundMessage + + loop, bus = self._make_loop() + order = [] + + async def mock_process(m, **kwargs): + order.append(f"start-{m.content}") + await asyncio.sleep(0.05) + order.append(f"end-{m.content}") + return OutboundMessage(channel="test", chat_id="c1", content=m.content) + + loop._process_message = mock_process + + msg1 = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="a") + msg2 = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="b") + + t1 = asyncio.create_task(loop._dispatch(msg1)) + t2 = asyncio.create_task(loop._dispatch(msg2)) + await asyncio.gather(t1, t2) + + # Should be serialized: start-a, end-a, start-b, end-b + assert order == ["start-a", "end-a", "start-b", "end-b"] + + +# --------------------------------------------------------------------------- From 2466b8b8433d04111cea36dc34d6a0ad7186ebda Mon Sep 17 00:00:00 2001 From: coldxiangyu Date: Wed, 25 Feb 2026 17:53:54 +0800 Subject: [PATCH 08/16] feat: /stop cancels spawned subagents via session tracking - SubagentManager tracks _session_tasks: session_key -> {task_id, ...} - cancel_by_session() cancels all subagents for a session - SpawnTool passes session_key through to SubagentManager - /stop response reports subagent cancellation count - Cleanup callback removes from both _running_tasks and _session_tasks Builds on #1179 --- nanobot/agent/loop.py | 11 +++- nanobot/agent/subagent.py | 34 +++++++++++-- nanobot/agent/tools/spawn.py | 3 ++ tests/test_task_cancel.py | 98 ++++++++++++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 4 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index fbd8d96..95ccc19 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -278,15 +278,24 @@ class AgentLoop: """Handle a command that must be processed while the agent may be busy.""" if cmd == "/stop": task = self._active_tasks.get(msg.session_key) + sub_cancelled = await self.subagents.cancel_by_session(msg.session_key) if task and not task.done(): task.cancel() try: await task except (asyncio.CancelledError, Exception): pass + parts = ["⏹ Task stopped."] + if sub_cancelled: + parts.append(f"Also stopped {sub_cancelled} background task(s).") await self.bus.publish_outbound(OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, - content="⏹ Task stopped.", + content=" ".join(parts), + )) + elif sub_cancelled: + await self.bus.publish_outbound(OutboundMessage( + channel=msg.channel, chat_id=msg.chat_id, + content=f"⏹ Stopped {sub_cancelled} background task(s).", )) else: await self.bus.publish_outbound(OutboundMessage( diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index d87c61a..78d972e 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -49,6 +49,7 @@ class SubagentManager: self.exec_config = exec_config or ExecToolConfig() self.restrict_to_workspace = restrict_to_workspace self._running_tasks: dict[str, asyncio.Task[None]] = {} + self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...} async def spawn( self, @@ -56,6 +57,7 @@ class SubagentManager: label: str | None = None, origin_channel: str = "cli", origin_chat_id: str = "direct", + session_key: str | None = None, ) -> str: """ Spawn a subagent to execute a task in the background. @@ -82,9 +84,20 @@ class SubagentManager: self._run_subagent(task_id, task, display_label, origin) ) self._running_tasks[task_id] = bg_task - - # Cleanup when done - bg_task.add_done_callback(lambda _: self._running_tasks.pop(task_id, None)) + + if session_key: + self._session_tasks.setdefault(session_key, set()).add(task_id) + + def _cleanup(_: asyncio.Task) -> None: + self._running_tasks.pop(task_id, None) + if session_key: + ids = self._session_tasks.get(session_key) + if ids: + ids.discard(task_id) + if not ids: + self._session_tasks.pop(session_key, None) + + bg_task.add_done_callback(_cleanup) logger.info("Spawned subagent [{}]: {}", task_id, display_label) return f"Subagent [{display_label}] started (id: {task_id}). I'll notify you when it completes." @@ -252,6 +265,21 @@ Skills are available at: {self.workspace}/skills/ (read SKILL.md files as needed When you have completed the task, provide a clear summary of your findings or actions.""" + async def cancel_by_session(self, session_key: str) -> int: + """Cancel all subagents spawned under the given session. Returns count cancelled.""" + task_ids = list(self._session_tasks.get(session_key, [])) + cancelled = 0 + for tid in task_ids: + t = self._running_tasks.get(tid) + if t and not t.done(): + t.cancel() + try: + await t + except (asyncio.CancelledError, Exception): + pass + cancelled += 1 + return cancelled + def get_running_count(self) -> int: """Return the number of currently running subagents.""" return len(self._running_tasks) diff --git a/nanobot/agent/tools/spawn.py b/nanobot/agent/tools/spawn.py index 33cf8e7..fb816ca 100644 --- a/nanobot/agent/tools/spawn.py +++ b/nanobot/agent/tools/spawn.py @@ -15,11 +15,13 @@ class SpawnTool(Tool): self._manager = manager self._origin_channel = "cli" self._origin_chat_id = "direct" + self._session_key = "cli:direct" def set_context(self, channel: str, chat_id: str) -> None: """Set the origin context for subagent announcements.""" self._origin_channel = channel self._origin_chat_id = chat_id + self._session_key = f"{channel}:{chat_id}" @property def name(self) -> str: @@ -57,4 +59,5 @@ class SpawnTool(Tool): label=label, origin_channel=self._origin_channel, origin_chat_id=self._origin_chat_id, + session_key=self._session_key, ) diff --git a/tests/test_task_cancel.py b/tests/test_task_cancel.py index 70e3f3a..5c0c4b7 100644 --- a/tests/test_task_cancel.py +++ b/tests/test_task_cancel.py @@ -218,3 +218,101 @@ class TestTaskCancellation: # --------------------------------------------------------------------------- + + +class TestSubagentCancellation: + """Tests for /stop cancelling subagents spawned under a session.""" + + @pytest.mark.asyncio + async def test_cancel_by_session(self): + """cancel_by_session cancels all tasks for that session.""" + from nanobot.agent.subagent import SubagentManager + from nanobot.bus.queue import MessageBus + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + mgr = SubagentManager(provider=provider, workspace=MagicMock(), bus=bus) + + cancelled = asyncio.Event() + + async def slow_subagent(): + try: + await asyncio.sleep(60) + except asyncio.CancelledError: + cancelled.set() + raise + + task = asyncio.create_task(slow_subagent()) + await asyncio.sleep(0) + tid = "sub-1" + session_key = "test:c1" + mgr._running_tasks[tid] = task + mgr._session_tasks[session_key] = {tid} + + count = await mgr.cancel_by_session(session_key) + assert count == 1 + assert cancelled.is_set() + assert task.cancelled() + + @pytest.mark.asyncio + async def test_cancel_by_session_no_tasks(self): + """cancel_by_session returns 0 when no subagents for session.""" + from nanobot.agent.subagent import SubagentManager + from nanobot.bus.queue import MessageBus + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + mgr = SubagentManager(provider=provider, workspace=MagicMock(), bus=bus) + + count = await mgr.cancel_by_session("nonexistent:session") + assert count == 0 + + @pytest.mark.asyncio + async def test_stop_cancels_subagents_via_loop(self): + """/stop on AgentLoop also cancels subagents for that session.""" + from nanobot.agent.loop import AgentLoop + from nanobot.bus.events import InboundMessage + from nanobot.bus.queue import MessageBus + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + workspace = MagicMock() + workspace.__truediv__ = MagicMock(return_value=MagicMock()) + + with patch("nanobot.agent.loop.ContextBuilder"), \ + patch("nanobot.agent.loop.SessionManager"), \ + patch("nanobot.agent.loop.SubagentManager"): + loop = AgentLoop(bus=bus, provider=provider, workspace=workspace) + + # Replace subagents with a real SubagentManager + from nanobot.agent.subagent import SubagentManager + loop.subagents = SubagentManager( + provider=provider, workspace=MagicMock(), bus=bus + ) + + cancelled = asyncio.Event() + session_key = "test:c1" + + async def slow_sub(): + try: + await asyncio.sleep(60) + except asyncio.CancelledError: + cancelled.set() + raise + + task = asyncio.create_task(slow_sub()) + await asyncio.sleep(0) + loop.subagents._running_tasks["sub-1"] = task + loop.subagents._session_tasks[session_key] = {"sub-1"} + + msg = InboundMessage( + channel="test", sender_id="u1", chat_id="c1", content="/stop" + ) + await loop._handle_immediate_command("/stop", msg) + + assert cancelled.is_set() + out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0) + assert "stopped" in out.content.lower() or "background" in out.content.lower() From 4768b9a09d043aef75f31a8b337cd07ebcab167b Mon Sep 17 00:00:00 2001 From: coldxiangyu Date: Wed, 25 Feb 2026 18:21:46 +0800 Subject: [PATCH 09/16] fix: parallel subagent cancellation + register task before lock - cancel_by_session: use asyncio.gather for parallel cancellation instead of sequential await per task - _dispatch: register in _active_tasks before acquiring lock so /stop can find queued tasks (synced from #1179) --- nanobot/agent/loop.py | 49 +++++++++++++++++++++------------------ nanobot/agent/subagent.py | 12 ++++------ 2 files changed, 32 insertions(+), 29 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 95ccc19..5e0b056 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -304,30 +304,35 @@ class AgentLoop: )) async def _dispatch(self, msg: InboundMessage) -> None: - """Dispatch a message for processing under the global lock.""" - async with self._processing_lock: - self._active_tasks[msg.session_key] = asyncio.current_task() # type: ignore[arg-type] - try: - response = await self._process_message(msg) - if response is not None: - await self.bus.publish_outbound(response) - elif msg.channel == "cli": + """Dispatch a message for processing under the global lock. + + The task is registered in _active_tasks *before* acquiring the lock + so that /stop can find (and cancel) tasks that are still queued. + """ + self._active_tasks[msg.session_key] = asyncio.current_task() # type: ignore[arg-type] + try: + async with self._processing_lock: + try: + response = await self._process_message(msg) + if response is not None: + await self.bus.publish_outbound(response) + elif msg.channel == "cli": + await self.bus.publish_outbound(OutboundMessage( + channel=msg.channel, chat_id=msg.chat_id, + content="", metadata=msg.metadata or {}, + )) + except asyncio.CancelledError: + logger.info("Task cancelled for session {}", msg.session_key) + # Response already sent by _handle_immediate_command + except Exception as e: + logger.error("Error processing message: {}", e) await self.bus.publish_outbound(OutboundMessage( - channel=msg.channel, chat_id=msg.chat_id, - content="", metadata=msg.metadata or {}, + channel=msg.channel, + chat_id=msg.chat_id, + content=f"Sorry, I encountered an error: {str(e)}" )) - except asyncio.CancelledError: - logger.info("Task cancelled for session {}", msg.session_key) - # Response already sent by _handle_immediate_command - except Exception as e: - logger.error("Error processing message: {}", e) - await self.bus.publish_outbound(OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content=f"Sorry, I encountered an error: {str(e)}" - )) - finally: - self._active_tasks.pop(msg.session_key, None) + finally: + self._active_tasks.pop(msg.session_key, None) async def close_mcp(self) -> None: """Close MCP connections.""" diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 78d972e..c28412b 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -268,17 +268,15 @@ When you have completed the task, provide a clear summary of your findings or ac async def cancel_by_session(self, session_key: str) -> int: """Cancel all subagents spawned under the given session. Returns count cancelled.""" task_ids = list(self._session_tasks.get(session_key, [])) - cancelled = 0 + to_cancel: list[asyncio.Task] = [] for tid in task_ids: t = self._running_tasks.get(tid) if t and not t.done(): t.cancel() - try: - await t - except (asyncio.CancelledError, Exception): - pass - cancelled += 1 - return cancelled + to_cancel.append(t) + if to_cancel: + await asyncio.gather(*to_cancel, return_exceptions=True) + return len(to_cancel) def get_running_count(self) -> int: """Return the number of currently running subagents.""" From f2e0847d644b96c9c77a41b9758a067dfa479e60 Mon Sep 17 00:00:00 2001 From: VITOHJL Date: Wed, 25 Feb 2026 23:27:41 +0800 Subject: [PATCH 10/16] Fix assistant messages without tool calls not being saved to session --- nanobot/agent/loop.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 8be8e51..d746af8 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -225,7 +225,16 @@ class AgentLoop: messages, tool_call.id, tool_call.name, result ) else: - final_content = self._strip_think(response.content) + clean = self._strip_think(response.content) + if on_progress and clean: + await on_progress(clean) + messages = self.context.add_assistant_message( + messages, + clean, + tool_calls=None, + reasoning_content=response.reasoning_content, + ) + final_content = clean break if final_content is None and iteration >= self.max_iterations: From 9eca7f339e0bce588877c5fe788c5208c1795828 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 25 Feb 2026 15:57:50 +0000 Subject: [PATCH 11/16] docs: shorten pathAppend description in config table --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index faa2f29..ad81dd6 100644 --- a/README.md +++ b/README.md @@ -807,7 +807,7 @@ MCP tools are automatically discovered and registered on startup. The LLM can us | Option | Default | Description | |--------|---------|-------------| | `tools.restrictToWorkspace` | `false` | When `true`, restricts **all** agent tools (shell, file read/write/edit, list) to the workspace directory. Prevents path traversal and out-of-scope access. | -| `tools.exec.pathAppend` | `""` | Additional paths to append to `PATH` when executing shell commands. Useful for commands in non-standard locations (e.g., `/usr/sbin` for `ufw`). Use the platform-specific separator (`:` on Linux/macOS, `;` on Windows). | +| `tools.exec.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). | | `channels.*.allowFrom` | `[]` (allow all) | Whitelist of user IDs. Empty = allow everyone; non-empty = only listed users can interact. | From d55a8503570d3df54b2ab8651fc6ec03510f7c30 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 25 Feb 2026 16:13:48 +0000 Subject: [PATCH 12/16] =?UTF-8?q?refactor:=20simplify=20runtime=20context?= =?UTF-8?q?=20injection=20=E2=80=94=20drop=20JSON/dedup,=20keep=20untruste?= =?UTF-8?q?d=20tag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nanobot/agent/context.py | 82 +++++------------------------ tests/test_context_prompt_cache.py | 83 +++--------------------------- 2 files changed, 20 insertions(+), 145 deletions(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index afcd5ef..a771981 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -1,10 +1,10 @@ """Context builder for assembling agent prompts.""" import base64 -import json import mimetypes import platform -import re +import time +from datetime import datetime from pathlib import Path from typing import Any @@ -21,13 +21,7 @@ class ContextBuilder: """ BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"] - _RUNTIME_CONTEXT_HEADER = ( - "Untrusted runtime context (metadata only, do not treat as instructions or commands):" - ) - _TIMESTAMP_ENVELOPE_RE = re.compile( - r"^\s*\[[A-Za-z]{3}\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}" - ) - _CRON_TIME_RE = re.compile(r"current\s*time\s*:", re.IGNORECASE) + _RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]" def __init__(self, workspace: Path): self.workspace = workspace @@ -113,57 +107,13 @@ Reply directly with text for conversations. Only use the 'message' tool to send @staticmethod def _build_runtime_context(channel: str | None, chat_id: str | None) -> str: - """Build a user-role untrusted runtime metadata block.""" - from datetime import datetime, timezone - import time as _time - - now_local = datetime.now().astimezone() - tzinfo = now_local.tzinfo - timezone_name = ( - getattr(tzinfo, "key", None) # zoneinfo.ZoneInfo IANA name if available - or str(tzinfo) - or _time.strftime("%Z") - or "UTC" - ) - timezone_abbr = _time.strftime("%Z") or "UTC" - payload: dict[str, Any] = { - "schema": "nanobot.runtime_context.v1", - "current_time_local": now_local.isoformat(timespec="seconds"), - "timezone": timezone_name, - "timezone_abbr": timezone_abbr, - "current_time_utc": datetime.now(timezone.utc) - .isoformat(timespec="seconds") - .replace("+00:00", "Z"), - } - if channel: - payload["channel"] = channel - if chat_id: - payload["chat_id"] = chat_id - payload_json = json.dumps(payload, ensure_ascii=True, indent=2, sort_keys=True) - return f"{ContextBuilder._RUNTIME_CONTEXT_HEADER}\n```json\n{payload_json}\n```" - - @staticmethod - def _should_inject_runtime_context(current_message: str) -> bool: - """ - Decide whether runtime metadata should be injected. - - Guardrails: - - Dedup if message already contains runtime metadata markers. - - Skip cron-style messages that already include "Current time:". - - Skip messages that already have a timestamp envelope prefix. - """ - stripped = current_message.strip() - if not stripped: - return True - if ContextBuilder._RUNTIME_CONTEXT_HEADER in current_message: - return False - if "[Runtime Context]" in current_message: - return False - if ContextBuilder._CRON_TIME_RE.search(current_message): - return False - if ContextBuilder._TIMESTAMP_ENVELOPE_RE.match(current_message): - return False - return True + """Build untrusted runtime metadata block for injection before the user message.""" + now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)") + tz = time.strftime("%Z") or "UTC" + lines = [f"Current Time: {now} ({tz})"] + if channel and chat_id: + lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"] + return ContextBuilder._RUNTIME_CONTEXT_TAG + "\n" + "\n".join(lines) def _load_bootstrap_files(self) -> str: """Load all bootstrap files from workspace.""" @@ -209,16 +159,10 @@ Reply directly with text for conversations. Only use the 'message' tool to send # History messages.extend(history) - # Dynamic runtime metadata is injected as a separate user-role untrusted context layer. - if self._should_inject_runtime_context(current_message): - messages.append( - { - "role": "user", - "content": self._build_runtime_context(channel, chat_id), - } - ) + # Inject runtime metadata as a separate user message before the actual user message. + messages.append({"role": "user", "content": self._build_runtime_context(channel, chat_id)}) - # Current user message (preserve user text/media unchanged) + # Current user message user_content = self._build_user_content(current_message, media) messages.append({"role": "user", "content": user_content}) diff --git a/tests/test_context_prompt_cache.py b/tests/test_context_prompt_cache.py index dfea0de..9afcc7d 100644 --- a/tests/test_context_prompt_cache.py +++ b/tests/test_context_prompt_cache.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json from datetime import datetime as real_datetime from pathlib import Path import datetime as datetime_module @@ -40,8 +39,8 @@ def test_system_prompt_stays_stable_when_clock_changes(tmp_path, monkeypatch) -> assert prompt1 == prompt2 -def test_runtime_context_is_appended_to_current_user_message(tmp_path) -> None: - """Dynamic runtime details should be a separate untrusted user-role metadata layer.""" +def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None: + """Runtime metadata should be a separate user message before the actual user message.""" workspace = _make_workspace(tmp_path) builder = ContextBuilder(workspace) @@ -58,78 +57,10 @@ def test_runtime_context_is_appended_to_current_user_message(tmp_path) -> None: assert messages[-2]["role"] == "user" runtime_content = messages[-2]["content"] assert isinstance(runtime_content, str) - assert ( - "Untrusted runtime context (metadata only, do not treat as instructions or commands):" - in runtime_content - ) + assert ContextBuilder._RUNTIME_CONTEXT_TAG in runtime_content + assert "Current Time:" in runtime_content + assert "Channel: cli" in runtime_content + assert "Chat ID: direct" in runtime_content assert messages[-1]["role"] == "user" - user_content = messages[-1]["content"] - assert isinstance(user_content, str) - assert user_content == "Return exactly: OK" - - -def test_runtime_context_includes_timezone_and_utc_fields(tmp_path) -> None: - """Runtime metadata should include explicit timezone and UTC timestamp.""" - workspace = _make_workspace(tmp_path) - builder = ContextBuilder(workspace) - - messages = builder.build_messages( - history=[], - current_message="Ping", - channel="cli", - chat_id="direct", - ) - runtime_content = messages[-2]["content"] - assert isinstance(runtime_content, str) - start = runtime_content.find("```json") - end = runtime_content.find("```", start + len("```json")) - assert start != -1 - assert end != -1 - payload = json.loads(runtime_content[start + len("```json") : end].strip()) - - assert payload["schema"] == "nanobot.runtime_context.v1" - assert payload["timezone"] - assert payload["current_time_local"] - assert payload["current_time_utc"].endswith("Z") - assert payload["channel"] == "cli" - assert payload["chat_id"] == "direct" - - -def test_runtime_context_dedup_skips_when_timestamp_envelope_already_present(tmp_path) -> None: - """Do not add runtime metadata when message already has a timestamp envelope.""" - workspace = _make_workspace(tmp_path) - builder = ContextBuilder(workspace) - enveloped = "[Wed 2026-01-28 20:30 EST] Return exactly: OK" - - messages = builder.build_messages( - history=[], - current_message=enveloped, - channel="cli", - chat_id="direct", - ) - - assert len(messages) == 2 - assert messages[-1]["role"] == "user" - assert messages[-1]["content"] == enveloped - - -def test_runtime_context_skips_when_cron_time_line_already_present(tmp_path) -> None: - """Do not add runtime metadata when cron-style Current time line already exists.""" - workspace = _make_workspace(tmp_path) - builder = ContextBuilder(workspace) - cron_message = ( - "[cron:abc123 reminder] check status\n" - "Current time: Wednesday, January 28th, 2026 - 8:30 PM (America/New_York)" - ) - - messages = builder.build_messages( - history=[], - current_message=cron_message, - channel="cli", - chat_id="direct", - ) - - assert len(messages) == 2 - assert messages[-1]["role"] == "user" - assert messages[-1]["content"] == cron_message + assert messages[-1]["content"] == "Return exactly: OK" From cdbede2fa89eb8c6c31ebbaab2f9276463e5231e Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 25 Feb 2026 17:04:08 +0000 Subject: [PATCH 13/16] refactor: simplify /stop dispatch, inline commands, trim verbose docstrings --- nanobot/agent/commands.py | 59 ------- nanobot/agent/context.py | 146 ++++-------------- nanobot/agent/loop.py | 126 ++++++--------- nanobot/agent/subagent.py | 60 ++------ nanobot/channels/telegram.py | 2 + nanobot/templates/AGENTS.md | 8 - tests/test_task_cancel.py | 287 +++++++++-------------------------- 7 files changed, 159 insertions(+), 529 deletions(-) delete mode 100644 nanobot/agent/commands.py diff --git a/nanobot/agent/commands.py b/nanobot/agent/commands.py deleted file mode 100644 index 2b4fd9b..0000000 --- a/nanobot/agent/commands.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Command definitions and dispatch for the agent loop. - -Commands are slash-prefixed messages (e.g. /stop, /new, /help) that are -handled specially — either immediately in the run() loop or inside -_process_message before the LLM is called. - -To add a new command: -1. Add a CommandDef to COMMANDS -2. If immediate=True, add a handler in AgentLoop._handle_immediate_command -3. If immediate=False, add handling in AgentLoop._process_message -""" - -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass(frozen=True) -class CommandDef: - """Definition of a slash command.""" - - name: str - description: str - immediate: bool = False # True = handled in run() loop, bypasses message processing - - -# Registry of all known commands. -# "immediate" commands are handled while the agent may be busy (e.g. /stop). -# Non-immediate commands go through normal _process_message flow. -COMMANDS: dict[str, CommandDef] = { - "/stop": CommandDef("/stop", "Stop the current task", immediate=True), - "/new": CommandDef("/new", "Start a new conversation"), - "/help": CommandDef("/help", "Show available commands"), -} - - -def parse_command(text: str) -> str | None: - """Extract a slash command from message text. - - Returns the command string (e.g. "/stop") or None if not a command. - """ - stripped = text.strip() - if not stripped.startswith("/"): - return None - return stripped.split()[0].lower() - - -def is_immediate_command(cmd: str) -> bool: - """Check if a command should be handled immediately, bypassing processing.""" - defn = COMMANDS.get(cmd) - return defn.immediate if defn else False - - -def get_help_text() -> str: - """Generate help text from registered commands.""" - lines = ["🐈 nanobot commands:"] - for defn in COMMANDS.values(): - lines.append(f"{defn.name} — {defn.description}") - return "\n".join(lines) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index a771981..03a9a89 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -13,12 +13,7 @@ from nanobot.agent.skills import SkillsLoader class ContextBuilder: - """ - Builds the context (system prompt + messages) for the agent. - - Assembles bootstrap files, memory, skills, and conversation history - into a coherent prompt for the LLM. - """ + """Builds the context (system prompt + messages) for the agent.""" BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"] _RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]" @@ -29,39 +24,23 @@ class ContextBuilder: self.skills = SkillsLoader(workspace) def build_system_prompt(self, skill_names: list[str] | None = None) -> str: - """ - Build the system prompt from bootstrap files, memory, and skills. - - Args: - skill_names: Optional list of skills to include. - - Returns: - Complete system prompt. - """ - parts = [] - - # Core identity - parts.append(self._get_identity()) - - # Bootstrap files + """Build the system prompt from identity, bootstrap files, memory, and skills.""" + parts = [self._get_identity()] + bootstrap = self._load_bootstrap_files() if bootstrap: parts.append(bootstrap) - - # Memory context + memory = self.memory.get_memory_context() if memory: parts.append(f"# Memory\n\n{memory}") - - # Skills - progressive loading - # 1. Always-loaded skills: include full content + always_skills = self.skills.get_always_skills() if always_skills: always_content = self.skills.load_skills_for_context(always_skills) if always_content: parts.append(f"# Active Skills\n\n{always_content}") - - # 2. Available skills: only show summary (agent uses read_file to load) + skills_summary = self.skills.build_skills_summary() if skills_summary: parts.append(f"""# Skills @@ -70,7 +49,7 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md Skills with available="false" need dependencies installed first - you can try installing them with apt/brew. {skills_summary}""") - + return "\n\n---\n\n".join(parts) def _get_identity(self) -> str: @@ -81,29 +60,25 @@ Skills with available="false" need dependencies installed first - you can try in return f"""# nanobot 🐈 -You are nanobot, a helpful AI assistant. +You are nanobot, a helpful AI assistant. ## Runtime {runtime} ## Workspace Your workspace is at: {workspace_path} -- Long-term memory: {workspace_path}/memory/MEMORY.md +- Long-term memory: {workspace_path}/memory/MEMORY.md (write important facts here) - History log: {workspace_path}/memory/HISTORY.md (grep-searchable) - Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md -Reply directly with text for conversations. Only use the 'message' tool to send to a specific chat channel. - -## Tool Call Guidelines -- Before calling tools, you may briefly state your intent (e.g. "Let me check that"), but NEVER predict or describe the expected result before receiving it. -- Before modifying a file, read it first to confirm its current content. -- Do not assume a file or directory exists — use list_dir or read_file to verify. +## nanobot Guidelines +- State intent before tool calls, but NEVER predict or claim results before receiving them. +- Before modifying a file, read it first. Do not assume files or directories exist. - After writing or editing a file, re-read it if accuracy matters. - If a tool call fails, analyze the error before retrying with a different approach. +- Ask for clarification when the request is ambiguous. -## Memory -- Remember important facts: write to {workspace_path}/memory/MEMORY.md -- Recall past events: grep {workspace_path}/memory/HISTORY.md""" +Reply directly with text for conversations. Only use the 'message' tool to send to a specific chat channel.""" @staticmethod def _build_runtime_context(channel: str | None, chat_id: str | None) -> str: @@ -136,37 +111,13 @@ Reply directly with text for conversations. Only use the 'message' tool to send channel: str | None = None, chat_id: str | None = None, ) -> list[dict[str, Any]]: - """ - Build the complete message list for an LLM call. - - Args: - history: Previous conversation messages. - current_message: The new user message. - skill_names: Optional skills to include. - media: Optional list of local file paths for images/media. - channel: Current channel (telegram, feishu, etc.). - chat_id: Current chat/user ID. - - Returns: - List of messages including system prompt. - """ - messages = [] - - # System prompt - system_prompt = self.build_system_prompt(skill_names) - messages.append({"role": "system", "content": system_prompt}) - - # History - messages.extend(history) - - # Inject runtime metadata as a separate user message before the actual user message. - messages.append({"role": "user", "content": self._build_runtime_context(channel, chat_id)}) - - # Current user message - user_content = self._build_user_content(current_message, media) - messages.append({"role": "user", "content": user_content}) - - return messages + """Build the complete message list for an LLM call.""" + return [ + {"role": "system", "content": self.build_system_prompt(skill_names)}, + *history, + {"role": "user", "content": self._build_runtime_context(channel, chat_id)}, + {"role": "user", "content": self._build_user_content(current_message, media)}, + ] 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.""" @@ -187,63 +138,24 @@ Reply directly with text for conversations. Only use the 'message' tool to send return images + [{"type": "text", "text": text}] def add_tool_result( - self, - messages: list[dict[str, Any]], - tool_call_id: str, - tool_name: str, - result: str + self, messages: list[dict[str, Any]], + tool_call_id: str, tool_name: str, result: str, ) -> list[dict[str, Any]]: - """ - Add a tool result to the message list. - - Args: - messages: Current message list. - tool_call_id: ID of the tool call. - tool_name: Name of the tool. - result: Tool execution result. - - Returns: - Updated message list. - """ - messages.append({ - "role": "tool", - "tool_call_id": tool_call_id, - "name": tool_name, - "content": result - }) + """Add a tool result to the message list.""" + messages.append({"role": "tool", "tool_call_id": tool_call_id, "name": tool_name, "content": result}) return messages def add_assistant_message( - self, - messages: list[dict[str, Any]], + self, messages: list[dict[str, Any]], content: str | None, tool_calls: list[dict[str, Any]] | None = None, reasoning_content: str | None = None, ) -> list[dict[str, Any]]: - """ - Add an assistant message to the message list. - - Args: - messages: Current message list. - content: Message content. - tool_calls: Optional tool calls. - reasoning_content: Thinking output (Kimi, DeepSeek-R1, etc.). - - Returns: - Updated message list. - """ - msg: dict[str, Any] = {"role": "assistant"} - - # Always include content — some providers (e.g. StepFun) reject - # assistant messages that omit the key entirely. - msg["content"] = content - + """Add an assistant message to the message list.""" + msg: dict[str, Any] = {"role": "assistant", "content": content} if tool_calls: msg["tool_calls"] = tool_calls - - # Include reasoning content when provided (required by some thinking models) if reasoning_content is not None: msg["reasoning_content"] = reasoning_content - messages.append(msg) return messages diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index e03f0e6..4155127 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -11,7 +11,6 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable from loguru import logger -from nanobot.agent.commands import get_help_text, is_immediate_command, parse_command from nanobot.agent.context import ContextBuilder from nanobot.agent.memory import MemoryStore from nanobot.agent.subagent import SubagentManager @@ -100,9 +99,8 @@ class AgentLoop: self._consolidating: set[str] = set() # Session keys with consolidation in progress self._consolidation_tasks: set[asyncio.Task] = set() # Strong refs to in-flight tasks self._consolidation_locks: dict[str, asyncio.Lock] = {} - self._active_tasks: dict[str, asyncio.Task] = {} # session_key -> running task - self._pending_tasks: set[asyncio.Task] = set() # Strong refs until dispatch starts - self._processing_lock = asyncio.Lock() # Serialize message processing + self._active_tasks: dict[str, list[asyncio.Task]] = {} # session_key -> tasks + self._processing_lock = asyncio.Lock() self._register_default_tools() def _register_default_tools(self) -> None: @@ -243,97 +241,61 @@ class AgentLoop: return final_content, tools_used, messages async def run(self) -> None: - """Run the agent loop, processing messages from the bus. - - Regular messages are dispatched as asyncio tasks so the loop stays - responsive to immediate commands like /stop. A global processing - lock serializes message handling to avoid shared-state races. - """ + """Run the agent loop, dispatching messages as tasks to stay responsive to /stop.""" self._running = True await self._connect_mcp() logger.info("Agent loop started") while self._running: try: - msg = await asyncio.wait_for( - self.bus.consume_inbound(), - timeout=1.0 - ) - - # Immediate commands (/stop) are handled inline - cmd = parse_command(msg.content) - if cmd and is_immediate_command(cmd): - await self._handle_immediate_command(cmd, msg) - continue - - # Regular messages (including non-immediate commands) are - # dispatched as tasks so the loop keeps consuming. - task = asyncio.create_task(self._dispatch(msg)) - self._pending_tasks.add(task) - task.add_done_callback(self._pending_tasks.discard) - + msg = await asyncio.wait_for(self.bus.consume_inbound(), timeout=1.0) except asyncio.TimeoutError: continue - async def _handle_immediate_command(self, cmd: str, msg: InboundMessage) -> None: - """Handle a command that must be processed while the agent may be busy.""" - if cmd == "/stop": - task = self._active_tasks.get(msg.session_key) - sub_cancelled = await self.subagents.cancel_by_session(msg.session_key) - if task and not task.done(): - task.cancel() - try: - await task - except (asyncio.CancelledError, Exception): - pass - parts = ["⏹ Task stopped."] - if sub_cancelled: - parts.append(f"Also stopped {sub_cancelled} background task(s).") - await self.bus.publish_outbound(OutboundMessage( - channel=msg.channel, chat_id=msg.chat_id, - content=" ".join(parts), - )) - elif sub_cancelled: - await self.bus.publish_outbound(OutboundMessage( - channel=msg.channel, chat_id=msg.chat_id, - content=f"⏹ Stopped {sub_cancelled} background task(s).", - )) + if msg.content.strip().lower() == "/stop": + await self._handle_stop(msg) else: - await self.bus.publish_outbound(OutboundMessage( - channel=msg.channel, chat_id=msg.chat_id, - content="No active task to stop.", - )) + task = asyncio.create_task(self._dispatch(msg)) + self._active_tasks.setdefault(msg.session_key, []).append(task) + task.add_done_callback(lambda t, k=msg.session_key: self._active_tasks.get(k, []) and self._active_tasks[k].remove(t) if t in self._active_tasks.get(k, []) else None) + + async def _handle_stop(self, msg: InboundMessage) -> None: + """Cancel all active tasks and subagents for the session.""" + tasks = self._active_tasks.pop(msg.session_key, []) + cancelled = sum(1 for t in tasks if not t.done() and t.cancel()) + for t in tasks: + try: + await t + except (asyncio.CancelledError, Exception): + pass + sub_cancelled = await self.subagents.cancel_by_session(msg.session_key) + total = cancelled + sub_cancelled + content = f"⏹ Stopped {total} task(s)." if total else "No active task to stop." + await self.bus.publish_outbound(OutboundMessage( + channel=msg.channel, chat_id=msg.chat_id, content=content, + )) async def _dispatch(self, msg: InboundMessage) -> None: - """Dispatch a message for processing under the global lock. - - The task is registered in _active_tasks *before* acquiring the lock - so that /stop can find (and cancel) tasks that are still queued. - """ - self._active_tasks[msg.session_key] = asyncio.current_task() # type: ignore[arg-type] - try: - async with self._processing_lock: - try: - response = await self._process_message(msg) - if response is not None: - await self.bus.publish_outbound(response) - elif msg.channel == "cli": - await self.bus.publish_outbound(OutboundMessage( - channel=msg.channel, chat_id=msg.chat_id, - content="", metadata=msg.metadata or {}, - )) - except asyncio.CancelledError: - logger.info("Task cancelled for session {}", msg.session_key) - # Response already sent by _handle_immediate_command - except Exception as e: - logger.error("Error processing message: {}", e) + """Process a message under the global lock.""" + async with self._processing_lock: + try: + response = await self._process_message(msg) + if response is not None: + await self.bus.publish_outbound(response) + elif msg.channel == "cli": await self.bus.publish_outbound(OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content=f"Sorry, I encountered an error: {str(e)}" + channel=msg.channel, chat_id=msg.chat_id, + content="", metadata=msg.metadata or {}, )) - finally: - self._active_tasks.pop(msg.session_key, None) + except asyncio.CancelledError: + logger.info("Task cancelled for session {}", msg.session_key) + raise + except Exception: + logger.exception("Error processing message for session {}", msg.session_key) + await self.bus.publish_outbound(OutboundMessage( + channel=msg.channel, chat_id=msg.chat_id, + content="Sorry, I encountered an error.", + )) async def close_mcp(self) -> None: """Close MCP connections.""" @@ -426,7 +388,7 @@ class AgentLoop: content="New session started.") if cmd == "/help": return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, - content=get_help_text()) + content="🐈 nanobot commands:\n/new — Start a new conversation\n/stop — Stop the current task\n/help — Show available commands") unconsolidated = len(session.messages) - session.last_consolidated if (unconsolidated >= self.memory_window and session.key not in self._consolidating): diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 1c1557e..337796c 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -18,13 +18,7 @@ from nanobot.agent.tools.web import WebSearchTool, WebFetchTool class SubagentManager: - """ - Manages background subagent execution. - - Subagents are lightweight agent instances that run in the background - to handle specific tasks. They share the same LLM provider but have - isolated context and a focused system prompt. - """ + """Manages background subagent execution.""" def __init__( self, @@ -59,43 +53,24 @@ class SubagentManager: origin_chat_id: str = "direct", session_key: str | None = None, ) -> str: - """ - Spawn a subagent to execute a task in the background. - - Args: - task: The task description for the subagent. - label: Optional human-readable label for the task. - origin_channel: The channel to announce results to. - origin_chat_id: The chat ID to announce results to. - - Returns: - Status message indicating the subagent was started. - """ + """Spawn a subagent to execute a task in the background.""" task_id = str(uuid.uuid4())[:8] display_label = label or task[:30] + ("..." if len(task) > 30 else "") - - origin = { - "channel": origin_channel, - "chat_id": origin_chat_id, - } - - # Create background task + origin = {"channel": origin_channel, "chat_id": origin_chat_id} + bg_task = asyncio.create_task( self._run_subagent(task_id, task, display_label, origin) ) self._running_tasks[task_id] = bg_task - if session_key: self._session_tasks.setdefault(session_key, set()).add(task_id) def _cleanup(_: asyncio.Task) -> None: self._running_tasks.pop(task_id, None) - if session_key: - ids = self._session_tasks.get(session_key) - if ids: - ids.discard(task_id) - if not ids: - self._session_tasks.pop(session_key, None) + if session_key and (ids := self._session_tasks.get(session_key)): + ids.discard(task_id) + if not ids: + del self._session_tasks[session_key] bg_task.add_done_callback(_cleanup) @@ -267,17 +242,14 @@ Skills are available at: {self.workspace}/skills/ (read SKILL.md files as needed When you have completed the task, provide a clear summary of your findings or actions.""" async def cancel_by_session(self, session_key: str) -> int: - """Cancel all subagents spawned under the given session. Returns count cancelled.""" - task_ids = list(self._session_tasks.get(session_key, [])) - to_cancel: list[asyncio.Task] = [] - for tid in task_ids: - t = self._running_tasks.get(tid) - if t and not t.done(): - t.cancel() - to_cancel.append(t) - if to_cancel: - await asyncio.gather(*to_cancel, return_exceptions=True) - return len(to_cancel) + """Cancel all subagents for the given session. Returns count cancelled.""" + tasks = [self._running_tasks[tid] for tid in self._session_tasks.get(session_key, []) + if tid in self._running_tasks and not self._running_tasks[tid].done()] + for t in tasks: + t.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + return len(tasks) def get_running_count(self) -> int: """Return the number of currently running subagents.""" diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 6cd98e7..808f50c 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -111,6 +111,7 @@ class TelegramChannel(BaseChannel): BOT_COMMANDS = [ BotCommand("start", "Start the bot"), BotCommand("new", "Start a new conversation"), + BotCommand("stop", "Stop the current task"), BotCommand("help", "Show available commands"), ] @@ -299,6 +300,7 @@ class TelegramChannel(BaseChannel): await update.message.reply_text( "🐈 nanobot commands:\n" "/new — Start a new conversation\n" + "/stop — Stop the current task\n" "/help — Show available commands" ) diff --git a/nanobot/templates/AGENTS.md b/nanobot/templates/AGENTS.md index 84ba657..4c3e5b1 100644 --- a/nanobot/templates/AGENTS.md +++ b/nanobot/templates/AGENTS.md @@ -2,14 +2,6 @@ You are a helpful AI assistant. Be concise, accurate, and friendly. -## Guidelines - -- Before calling tools, briefly state your intent — but NEVER predict results before receiving them -- Use precise tense: "I will run X" before the call, "X returned Y" after -- NEVER claim success before a tool result confirms it -- Ask for clarification when the request is ambiguous -- Remember important information in `memory/MEMORY.md`; past events are logged in `memory/HISTORY.md` - ## Scheduled Reminders When user asks for a reminder at a specific time, use `exec` to run: diff --git a/tests/test_task_cancel.py b/tests/test_task_cancel.py index 5c0c4b7..27a2d73 100644 --- a/tests/test_task_cancel.py +++ b/tests/test_task_cancel.py @@ -1,4 +1,4 @@ -"""Tests for the command system and task cancellation.""" +"""Tests for /stop task cancellation.""" from __future__ import annotations @@ -7,117 +7,42 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from nanobot.agent.commands import ( - COMMANDS, - get_help_text, - is_immediate_command, - parse_command, -) + +def _make_loop(): + """Create a minimal AgentLoop with mocked dependencies.""" + from nanobot.agent.loop import AgentLoop + from nanobot.bus.queue import MessageBus + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + workspace = MagicMock() + workspace.__truediv__ = MagicMock(return_value=MagicMock()) + + with patch("nanobot.agent.loop.ContextBuilder"), \ + patch("nanobot.agent.loop.SessionManager"), \ + patch("nanobot.agent.loop.SubagentManager") as MockSubMgr: + MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0) + loop = AgentLoop(bus=bus, provider=provider, workspace=workspace) + return loop, bus -# --------------------------------------------------------------------------- -# commands.py unit tests -# --------------------------------------------------------------------------- - -class TestParseCommand: - def test_slash_command(self): - assert parse_command("/stop") == "/stop" - - def test_slash_command_with_args(self): - assert parse_command("/new some args") == "/new" - - def test_not_a_command(self): - assert parse_command("hello world") is None - - def test_empty_string(self): - assert parse_command("") is None - - def test_leading_whitespace(self): - assert parse_command(" /help") == "/help" - - def test_uppercase_normalized(self): - assert parse_command("/STOP") == "/stop" - - -class TestIsImmediateCommand: - def test_stop_is_immediate(self): - assert is_immediate_command("/stop") is True - - def test_new_is_not_immediate(self): - assert is_immediate_command("/new") is False - - def test_help_is_not_immediate(self): - assert is_immediate_command("/help") is False - - def test_unknown_command(self): - assert is_immediate_command("/unknown") is False - - -class TestGetHelpText: - def test_contains_all_commands(self): - text = get_help_text() - for cmd in COMMANDS: - assert cmd in text - - def test_contains_descriptions(self): - text = get_help_text() - for defn in COMMANDS.values(): - assert defn.description in text - - def test_starts_with_header(self): - assert get_help_text().startswith("🐈") - - -# --------------------------------------------------------------------------- -# Task cancellation integration tests -# --------------------------------------------------------------------------- - -class TestTaskCancellation: - """Tests for /stop cancelling an active task in AgentLoop.""" - - def _make_loop(self): - """Create a minimal AgentLoop with mocked dependencies.""" - from nanobot.agent.loop import AgentLoop - from nanobot.bus.queue import MessageBus - - bus = MessageBus() - provider = MagicMock() - provider.get_default_model.return_value = "test-model" - workspace = MagicMock() - workspace.__truediv__ = MagicMock(return_value=MagicMock()) - - with patch("nanobot.agent.loop.ContextBuilder"), \ - patch("nanobot.agent.loop.SessionManager"), \ - patch("nanobot.agent.loop.SubagentManager") as MockSubMgr: - MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0) - loop = AgentLoop( - bus=bus, - provider=provider, - workspace=workspace, - ) - return loop, bus - +class TestHandleStop: @pytest.mark.asyncio async def test_stop_no_active_task(self): - """'/stop' when nothing is running returns 'No active task'.""" from nanobot.bus.events import InboundMessage - loop, bus = self._make_loop() - msg = InboundMessage( - channel="test", sender_id="u1", chat_id="c1", content="/stop" - ) - await loop._handle_immediate_command("/stop", msg) + loop, bus = _make_loop() + msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="/stop") + await loop._handle_stop(msg) out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0) assert "No active task" in out.content @pytest.mark.asyncio async def test_stop_cancels_active_task(self): - """'/stop' cancels a running task.""" from nanobot.bus.events import InboundMessage - loop, bus = self._make_loop() - session_key = "test:c1" - + loop, bus = _make_loop() cancelled = asyncio.Event() async def slow_task(): @@ -128,74 +53,61 @@ class TestTaskCancellation: raise task = asyncio.create_task(slow_task()) - await asyncio.sleep(0) # Let task enter its await - loop._active_tasks[session_key] = task + await asyncio.sleep(0) + loop._active_tasks["test:c1"] = [task] - msg = InboundMessage( - channel="test", sender_id="u1", chat_id="c1", content="/stop" - ) - await loop._handle_immediate_command("/stop", msg) + msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="/stop") + await loop._handle_stop(msg) assert cancelled.is_set() - assert task.cancelled() out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0) assert "stopped" in out.content.lower() @pytest.mark.asyncio - async def test_dispatch_registers_and_clears_task(self): - """_dispatch registers the task in _active_tasks and clears it after.""" + async def test_stop_cancels_multiple_tasks(self): + from nanobot.bus.events import InboundMessage + + loop, bus = _make_loop() + events = [asyncio.Event(), asyncio.Event()] + + async def slow(idx): + try: + await asyncio.sleep(60) + except asyncio.CancelledError: + events[idx].set() + raise + + tasks = [asyncio.create_task(slow(i)) for i in range(2)] + await asyncio.sleep(0) + loop._active_tasks["test:c1"] = tasks + + msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="/stop") + await loop._handle_stop(msg) + + assert all(e.is_set() for e in events) + out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0) + assert "2 task" in out.content + + +class TestDispatch: + @pytest.mark.asyncio + async def test_dispatch_processes_and_publishes(self): from nanobot.bus.events import InboundMessage, OutboundMessage - loop, bus = self._make_loop() - msg = InboundMessage( - channel="test", sender_id="u1", chat_id="c1", content="hello" - ) - - # Mock _process_message to return a simple response + loop, bus = _make_loop() + msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="hello") loop._process_message = AsyncMock( return_value=OutboundMessage(channel="test", chat_id="c1", content="hi") ) - - task = asyncio.create_task(loop._dispatch(msg)) - await task - - # Task should be cleaned up - assert msg.session_key not in loop._active_tasks - - @pytest.mark.asyncio - async def test_dispatch_handles_cancelled_error(self): - """_dispatch catches CancelledError gracefully.""" - from nanobot.bus.events import InboundMessage - - loop, bus = self._make_loop() - msg = InboundMessage( - channel="test", sender_id="u1", chat_id="c1", content="hello" - ) - - async def mock_process(m, **kwargs): - await asyncio.sleep(60) - - loop._process_message = mock_process - - task = asyncio.create_task(loop._dispatch(msg)) - await asyncio.sleep(0.05) # Let task start - - assert msg.session_key in loop._active_tasks - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - - # Task should be cleaned up even after cancel - assert msg.session_key not in loop._active_tasks + await loop._dispatch(msg) + out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0) + assert out.content == "hi" @pytest.mark.asyncio async def test_processing_lock_serializes(self): - """Only one message processes at a time due to _processing_lock.""" from nanobot.bus.events import InboundMessage, OutboundMessage - loop, bus = self._make_loop() + loop, bus = _make_loop() order = [] async def mock_process(m, **kwargs): @@ -205,27 +117,18 @@ class TestTaskCancellation: return OutboundMessage(channel="test", chat_id="c1", content=m.content) loop._process_message = mock_process - msg1 = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="a") msg2 = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="b") t1 = asyncio.create_task(loop._dispatch(msg1)) t2 = asyncio.create_task(loop._dispatch(msg2)) await asyncio.gather(t1, t2) - - # Should be serialized: start-a, end-a, start-b, end-b assert order == ["start-a", "end-a", "start-b", "end-b"] -# --------------------------------------------------------------------------- - - class TestSubagentCancellation: - """Tests for /stop cancelling subagents spawned under a session.""" - @pytest.mark.asyncio async def test_cancel_by_session(self): - """cancel_by_session cancels all tasks for that session.""" from nanobot.agent.subagent import SubagentManager from nanobot.bus.queue import MessageBus @@ -236,28 +139,24 @@ class TestSubagentCancellation: cancelled = asyncio.Event() - async def slow_subagent(): + async def slow(): try: await asyncio.sleep(60) except asyncio.CancelledError: cancelled.set() raise - task = asyncio.create_task(slow_subagent()) + task = asyncio.create_task(slow()) await asyncio.sleep(0) - tid = "sub-1" - session_key = "test:c1" - mgr._running_tasks[tid] = task - mgr._session_tasks[session_key] = {tid} + mgr._running_tasks["sub-1"] = task + mgr._session_tasks["test:c1"] = {"sub-1"} - count = await mgr.cancel_by_session(session_key) + count = await mgr.cancel_by_session("test:c1") assert count == 1 assert cancelled.is_set() - assert task.cancelled() @pytest.mark.asyncio async def test_cancel_by_session_no_tasks(self): - """cancel_by_session returns 0 when no subagents for session.""" from nanobot.agent.subagent import SubagentManager from nanobot.bus.queue import MessageBus @@ -265,54 +164,4 @@ class TestSubagentCancellation: provider = MagicMock() provider.get_default_model.return_value = "test-model" mgr = SubagentManager(provider=provider, workspace=MagicMock(), bus=bus) - - count = await mgr.cancel_by_session("nonexistent:session") - assert count == 0 - - @pytest.mark.asyncio - async def test_stop_cancels_subagents_via_loop(self): - """/stop on AgentLoop also cancels subagents for that session.""" - from nanobot.agent.loop import AgentLoop - from nanobot.bus.events import InboundMessage - from nanobot.bus.queue import MessageBus - - bus = MessageBus() - provider = MagicMock() - provider.get_default_model.return_value = "test-model" - workspace = MagicMock() - workspace.__truediv__ = MagicMock(return_value=MagicMock()) - - with patch("nanobot.agent.loop.ContextBuilder"), \ - patch("nanobot.agent.loop.SessionManager"), \ - patch("nanobot.agent.loop.SubagentManager"): - loop = AgentLoop(bus=bus, provider=provider, workspace=workspace) - - # Replace subagents with a real SubagentManager - from nanobot.agent.subagent import SubagentManager - loop.subagents = SubagentManager( - provider=provider, workspace=MagicMock(), bus=bus - ) - - cancelled = asyncio.Event() - session_key = "test:c1" - - async def slow_sub(): - try: - await asyncio.sleep(60) - except asyncio.CancelledError: - cancelled.set() - raise - - task = asyncio.create_task(slow_sub()) - await asyncio.sleep(0) - loop.subagents._running_tasks["sub-1"] = task - loop.subagents._session_tasks[session_key] = {"sub-1"} - - msg = InboundMessage( - channel="test", sender_id="u1", chat_id="c1", content="/stop" - ) - await loop._handle_immediate_command("/stop", msg) - - assert cancelled.is_set() - out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0) - assert "stopped" in out.content.lower() or "background" in out.content.lower() + assert await mgr.cancel_by_session("nonexistent") == 0 From 65477e4bf34f6bfbca81096db203bfa3f90dc9a9 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 26 Feb 2026 02:15:42 +0000 Subject: [PATCH 14/16] feat: support explicit provider selection in config --- nanobot/config/schema.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 32b85cf..b030dac 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -186,6 +186,7 @@ class AgentDefaults(Base): workspace: str = "~/.nanobot/workspace" model: str = "anthropic/claude-opus-4-5" + provider: str = "auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection max_tokens: int = 8192 temperature: float = 0.1 max_tool_iterations: int = 40 @@ -301,6 +302,11 @@ class Config(BaseSettings): """Match provider config and its registry name. Returns (config, spec_name).""" from nanobot.providers.registry import PROVIDERS + forced = self.agents.defaults.provider + if forced != "auto": + p = getattr(self.providers, forced, None) + return (p, forced) if p else (None, None) + model_lower = (model or self.agents.defaults.model).lower() model_normalized = model_lower.replace("-", "_") model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else "" From 7e1a08d33c2e609502190f1f1e6e4e53e3f2b440 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 26 Feb 2026 02:23:07 +0000 Subject: [PATCH 15/16] docs: add provider option to Quick Start config example --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ad81dd6..ec12cfe 100644 --- a/README.md +++ b/README.md @@ -138,12 +138,13 @@ Add or merge these **two parts** into your config (other options have defaults). } ``` -*Set your model*: +*Set your model* (optionally pin a provider — defaults to auto-detection): ```json { "agents": { "defaults": { - "model": "anthropic/claude-opus-4-5" + "model": "anthropic/claude-opus-4-5", + "provider": "openrouter" } } } From 3902e31165a5c832c68ea02ceae787d0ea1545e6 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 26 Feb 2026 02:33:38 +0000 Subject: [PATCH 16/16] refactor: drop redundant tool_calls=None in final assistant message --- nanobot/agent/loop.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index ba936d2..4adc798 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -232,10 +232,7 @@ class AgentLoop: if on_progress and clean: await on_progress(clean) messages = self.context.add_assistant_message( - messages, - clean, - tool_calls=None, - reasoning_content=response.reasoning_content, + messages, clean, reasoning_content=response.reasoning_content, ) final_content = clean break