diff --git a/core_agent_lines.sh b/core_agent_lines.sh index df32394..d35207c 100755 --- a/core_agent_lines.sh +++ b/core_agent_lines.sh @@ -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 "*/providers/*" ! -path "*/skills/*" | xargs cat | wc -l) +total=$(find nanobot -name "*.py" ! -path "*/channels/*" ! -path "*/cli/*" ! -path "*/command/*" ! -path "*/providers/*" ! -path "*/skills/*" | xargs cat | wc -l) echo " Core total: $total lines" echo "" -echo " (excludes: channels/, cli/, providers/, skills/)" +echo " (excludes: channels/, cli/, command/, providers/, skills/)" diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index a892d3d..e9f6def 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -4,9 +4,7 @@ from __future__ import annotations import asyncio import json -import os import re -import sys import time from contextlib import AsyncExitStack from pathlib import Path @@ -14,7 +12,6 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable from loguru import logger -from nanobot import __version__ from nanobot.agent.context import ContextBuilder from nanobot.agent.memory import MemoryConsolidator from nanobot.agent.subagent import SubagentManager @@ -27,7 +24,7 @@ from nanobot.agent.tools.shell import ExecTool from nanobot.agent.tools.spawn import SpawnTool from nanobot.agent.tools.web import WebFetchTool, WebSearchTool from nanobot.bus.events import InboundMessage, OutboundMessage -from nanobot.utils.helpers import build_status_content +from nanobot.command import CommandContext, CommandRouter, register_builtin_commands from nanobot.bus.queue import MessageBus from nanobot.providers.base import LLMProvider from nanobot.session.manager import Session, SessionManager @@ -118,6 +115,8 @@ class AgentLoop: max_completion_tokens=provider.generation.max_tokens, ) self._register_default_tools() + self.commands = CommandRouter() + register_builtin_commands(self.commands) def _register_default_tools(self) -> None: """Register the default set of tools.""" @@ -188,28 +187,6 @@ class AgentLoop: return f'{tc.name}("{val[:40]}…")' if len(val) > 40 else f'{tc.name}("{val}")' return ", ".join(_fmt(tc) for tc in tool_calls) - def _status_response(self, msg: InboundMessage, session: Session) -> OutboundMessage: - """Build an outbound status message for a session.""" - ctx_est = 0 - try: - ctx_est, _ = self.memory_consolidator.estimate_session_prompt_tokens(session) - except Exception: - pass - if ctx_est <= 0: - ctx_est = self._last_usage.get("prompt_tokens", 0) - return OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content=build_status_content( - version=__version__, model=self.model, - start_time=self._start_time, last_usage=self._last_usage, - context_window_tokens=self.context_window_tokens, - session_msg_count=len(session.get_history(max_messages=0)), - context_tokens_estimate=ctx_est, - ), - metadata={"render_as": "text"}, - ) - async def _run_agent_loop( self, initial_messages: list[dict], @@ -348,48 +325,16 @@ class AgentLoop: logger.warning("Error consuming inbound message: {}, continuing...", e) continue - cmd = msg.content.strip().lower() - if cmd == "/stop": - await self._handle_stop(msg) - elif cmd == "/restart": - await self._handle_restart(msg) - elif cmd == "/status": - session = self.sessions.get_or_create(msg.session_key) - await self.bus.publish_outbound(self._status_response(msg, session)) - else: - 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 _handle_restart(self, msg: InboundMessage) -> None: - """Restart the process in-place via os.execv.""" - await self.bus.publish_outbound(OutboundMessage( - channel=msg.channel, chat_id=msg.chat_id, content="Restarting...", - )) - - async def _do_restart(): - await asyncio.sleep(1) - # Use -m nanobot instead of sys.argv[0] for Windows compatibility - # (sys.argv[0] may be just "nanobot" without full path on Windows) - os.execv(sys.executable, [sys.executable, "-m", "nanobot"] + sys.argv[1:]) - - asyncio.create_task(_do_restart()) + raw = msg.content.strip() + if self.commands.is_priority(raw): + ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw=raw, loop=self) + result = await self.commands.dispatch_priority(ctx) + if result: + await self.bus.publish_outbound(result) + continue + 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 _dispatch(self, msg: InboundMessage) -> None: """Process a message under the global lock.""" @@ -491,35 +436,11 @@ class AgentLoop: session = self.sessions.get_or_create(key) # Slash commands - cmd = msg.content.strip().lower() - if cmd == "/new": - snapshot = session.messages[session.last_consolidated:] - session.clear() - self.sessions.save(session) - self.sessions.invalidate(session.key) + raw = msg.content.strip() + ctx = CommandContext(msg=msg, session=session, key=key, raw=raw, loop=self) + if result := await self.commands.dispatch(ctx): + return result - if snapshot: - self._schedule_background(self.memory_consolidator.archive_messages(snapshot)) - - return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, - content="New session started.") - if cmd == "/status": - return self._status_response(msg, session) - if cmd == "/help": - lines = [ - "🐈 nanobot commands:", - "/new — Start a new conversation", - "/stop — Stop the current task", - "/restart — Restart the bot", - "/status — Show bot status", - "/help — Show available commands", - ] - return OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content="\n".join(lines), - metadata={"render_as": "text"}, - ) await self.memory_consolidator.maybe_consolidate_by_tokens(session) self._set_tool_context(msg.channel, msg.chat_id, msg.metadata.get("message_id")) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index d0ec145..8354a83 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -294,7 +294,7 @@ def onboard( # Run interactive wizard if enabled if wizard: - from nanobot.cli.onboard_wizard import run_onboard + from nanobot.cli.onboard import run_onboard try: result = run_onboard(initial_config=config) diff --git a/nanobot/cli/model_info.py b/nanobot/cli/models.py similarity index 100% rename from nanobot/cli/model_info.py rename to nanobot/cli/models.py diff --git a/nanobot/cli/onboard_wizard.py b/nanobot/cli/onboard.py similarity index 99% rename from nanobot/cli/onboard_wizard.py rename to nanobot/cli/onboard.py index eca86bf..4e3b6e5 100644 --- a/nanobot/cli/onboard_wizard.py +++ b/nanobot/cli/onboard.py @@ -16,7 +16,7 @@ from rich.console import Console from rich.panel import Panel from rich.table import Table -from nanobot.cli.model_info import ( +from nanobot.cli.models import ( format_token_count, get_model_context_limit, get_model_suggestions, diff --git a/nanobot/command/__init__.py b/nanobot/command/__init__.py new file mode 100644 index 0000000..84e7138 --- /dev/null +++ b/nanobot/command/__init__.py @@ -0,0 +1,6 @@ +"""Slash command routing and built-in handlers.""" + +from nanobot.command.builtin import register_builtin_commands +from nanobot.command.router import CommandContext, CommandRouter + +__all__ = ["CommandContext", "CommandRouter", "register_builtin_commands"] diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py new file mode 100644 index 0000000..0a9af3c --- /dev/null +++ b/nanobot/command/builtin.py @@ -0,0 +1,110 @@ +"""Built-in slash command handlers.""" + +from __future__ import annotations + +import asyncio +import os +import sys + +from nanobot import __version__ +from nanobot.bus.events import OutboundMessage +from nanobot.command.router import CommandContext, CommandRouter +from nanobot.utils.helpers import build_status_content + + +async def cmd_stop(ctx: CommandContext) -> OutboundMessage: + """Cancel all active tasks and subagents for the session.""" + loop = ctx.loop + msg = ctx.msg + tasks = loop._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 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) + + +async def cmd_restart(ctx: CommandContext) -> OutboundMessage: + """Restart the process in-place via os.execv.""" + msg = ctx.msg + + async def _do_restart(): + await asyncio.sleep(1) + 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...") + + +async def cmd_status(ctx: CommandContext) -> OutboundMessage: + """Build an outbound status message for a session.""" + loop = ctx.loop + session = ctx.session or loop.sessions.get_or_create(ctx.key) + ctx_est = 0 + try: + ctx_est, _ = loop.memory_consolidator.estimate_session_prompt_tokens(session) + except Exception: + pass + if ctx_est <= 0: + ctx_est = loop._last_usage.get("prompt_tokens", 0) + return OutboundMessage( + channel=ctx.msg.channel, + chat_id=ctx.msg.chat_id, + content=build_status_content( + version=__version__, model=loop.model, + start_time=loop._start_time, last_usage=loop._last_usage, + context_window_tokens=loop.context_window_tokens, + session_msg_count=len(session.get_history(max_messages=0)), + context_tokens_estimate=ctx_est, + ), + metadata={"render_as": "text"}, + ) + + +async def cmd_new(ctx: CommandContext) -> OutboundMessage: + """Start a fresh session.""" + loop = ctx.loop + session = ctx.session or loop.sessions.get_or_create(ctx.key) + snapshot = session.messages[session.last_consolidated:] + session.clear() + loop.sessions.save(session) + loop.sessions.invalidate(session.key) + if snapshot: + loop._schedule_background(loop.memory_consolidator.archive_messages(snapshot)) + return OutboundMessage( + channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, + content="New session started.", + ) + + +async def cmd_help(ctx: CommandContext) -> OutboundMessage: + """Return available slash commands.""" + lines = [ + "🐈 nanobot commands:", + "/new — Start a new conversation", + "/stop — Stop the current task", + "/restart — Restart the bot", + "/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"}, + ) + + +def register_builtin_commands(router: CommandRouter) -> None: + """Register the default set of slash commands.""" + router.priority("/stop", cmd_stop) + router.priority("/restart", cmd_restart) + router.priority("/status", cmd_status) + router.exact("/new", cmd_new) + router.exact("/status", cmd_status) + router.exact("/help", cmd_help) diff --git a/nanobot/command/router.py b/nanobot/command/router.py new file mode 100644 index 0000000..35a4754 --- /dev/null +++ b/nanobot/command/router.py @@ -0,0 +1,84 @@ +"""Minimal command routing table for slash commands.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Awaitable, Callable + +if TYPE_CHECKING: + from nanobot.bus.events import InboundMessage, OutboundMessage + from nanobot.session.manager import Session + +Handler = Callable[["CommandContext"], Awaitable["OutboundMessage | None"]] + + +@dataclass +class CommandContext: + """Everything a command handler needs to produce a response.""" + + msg: InboundMessage + session: Session | None + key: str + raw: str + args: str = "" + loop: Any = None + + +class CommandRouter: + """Pure dict-based command dispatch. + + Three tiers checked in order: + 1. *priority* — exact-match commands handled before the dispatch lock + (e.g. /stop, /restart). + 2. *exact* — exact-match commands handled inside the dispatch lock. + 3. *prefix* — longest-prefix-first match (e.g. "/team "). + 4. *interceptors* — fallback predicates (e.g. team-mode active check). + """ + + def __init__(self) -> None: + self._priority: dict[str, Handler] = {} + self._exact: dict[str, Handler] = {} + self._prefix: list[tuple[str, Handler]] = [] + self._interceptors: list[Handler] = [] + + def priority(self, cmd: str, handler: Handler) -> None: + self._priority[cmd] = handler + + def exact(self, cmd: str, handler: Handler) -> None: + self._exact[cmd] = handler + + def prefix(self, pfx: str, handler: Handler) -> None: + self._prefix.append((pfx, handler)) + self._prefix.sort(key=lambda p: len(p[0]), reverse=True) + + def intercept(self, handler: Handler) -> None: + self._interceptors.append(handler) + + def is_priority(self, text: str) -> bool: + return text.strip().lower() in self._priority + + async def dispatch_priority(self, ctx: CommandContext) -> OutboundMessage | None: + """Dispatch a priority command. Called from run() without the lock.""" + handler = self._priority.get(ctx.raw.lower()) + if handler: + return await handler(ctx) + return None + + async def dispatch(self, ctx: CommandContext) -> OutboundMessage | None: + """Try exact, prefix, then interceptors. Returns None if unhandled.""" + cmd = ctx.raw.lower() + + if handler := self._exact.get(cmd): + return await handler(ctx) + + for pfx, handler in self._prefix: + if cmd.startswith(pfx): + ctx.args = ctx.raw[len(pfx):] + return await handler(ctx) + + for interceptor in self._interceptors: + result = await interceptor(ctx) + if result is not None: + return result + + return None diff --git a/tests/test_commands.py b/tests/test_commands.py index 0265bb3..09b74f2 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -138,10 +138,10 @@ def test_onboard_help_shows_workspace_and_config_options(): def test_onboard_interactive_discard_does_not_save_or_create_workspace(mock_paths, monkeypatch): config_file, workspace_dir, _ = mock_paths - from nanobot.cli.onboard_wizard import OnboardResult + from nanobot.cli.onboard import OnboardResult monkeypatch.setattr( - "nanobot.cli.onboard_wizard.run_onboard", + "nanobot.cli.onboard.run_onboard", lambda initial_config: OnboardResult(config=initial_config, should_save=False), ) @@ -179,10 +179,10 @@ def test_onboard_wizard_preserves_explicit_config_in_next_steps(tmp_path, monkey config_path = tmp_path / "instance" / "config.json" workspace_path = tmp_path / "workspace" - from nanobot.cli.onboard_wizard import OnboardResult + from nanobot.cli.onboard import OnboardResult monkeypatch.setattr( - "nanobot.cli.onboard_wizard.run_onboard", + "nanobot.cli.onboard.run_onboard", lambda initial_config: OnboardResult(config=initial_config, should_save=True), ) monkeypatch.setattr("nanobot.channels.registry.discover_all", lambda: {}) diff --git a/tests/test_onboard_logic.py b/tests/test_onboard_logic.py index 9e0f6f7..43999f9 100644 --- a/tests/test_onboard_logic.py +++ b/tests/test_onboard_logic.py @@ -12,11 +12,11 @@ from typing import Any, cast import pytest from pydantic import BaseModel, Field -from nanobot.cli import onboard_wizard +from nanobot.cli import onboard as onboard_wizard # Import functions to test from nanobot.cli.commands import _merge_missing_defaults -from nanobot.cli.onboard_wizard import ( +from nanobot.cli.onboard import ( _BACK_PRESSED, _configure_pydantic_model, _format_value, @@ -352,7 +352,7 @@ class TestProviderChannelInfo: """Tests for provider and channel info retrieval.""" def test_get_provider_names_returns_dict(self): - from nanobot.cli.onboard_wizard import _get_provider_names + from nanobot.cli.onboard import _get_provider_names names = _get_provider_names() assert isinstance(names, dict) @@ -363,7 +363,7 @@ class TestProviderChannelInfo: assert "github_copilot" not in names def test_get_channel_names_returns_dict(self): - from nanobot.cli.onboard_wizard import _get_channel_names + from nanobot.cli.onboard import _get_channel_names names = _get_channel_names() assert isinstance(names, dict) @@ -371,7 +371,7 @@ class TestProviderChannelInfo: assert len(names) >= 0 def test_get_provider_info_returns_valid_structure(self): - from nanobot.cli.onboard_wizard import _get_provider_info + from nanobot.cli.onboard import _get_provider_info info = _get_provider_info() assert isinstance(info, dict) diff --git a/tests/test_restart_command.py b/tests/test_restart_command.py index 0330f81..3281afe 100644 --- a/tests/test_restart_command.py +++ b/tests/test_restart_command.py @@ -34,12 +34,15 @@ class TestRestartCommand: @pytest.mark.asyncio async def test_restart_sends_message_and_calls_execv(self): + from nanobot.command.builtin import cmd_restart + 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.agent.loop.os.execv") as mock_execv: - await loop._handle_restart(msg) - out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0) + with patch("nanobot.command.builtin.os.execv") as mock_execv: + out = await cmd_restart(ctx) assert "Restarting" in out.content await asyncio.sleep(1.5) @@ -51,8 +54,8 @@ class TestRestartCommand: loop, bus = _make_loop() msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/restart") - with patch.object(loop, "_handle_restart") as mock_handle: - mock_handle.return_value = None + with patch.object(loop, "_dispatch", new_callable=AsyncMock) as mock_dispatch, \ + patch("nanobot.command.builtin.os.execv"): await bus.publish_inbound(msg) loop._running = True @@ -65,7 +68,9 @@ class TestRestartCommand: except asyncio.CancelledError: pass - mock_handle.assert_called_once() + mock_dispatch.assert_not_called() + out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0) + assert "Restarting" in out.content @pytest.mark.asyncio async def test_status_intercepted_in_run_loop(self): @@ -73,10 +78,7 @@ class TestRestartCommand: loop, bus = _make_loop() msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/status") - with patch.object(loop, "_status_response") as mock_status: - mock_status.return_value = OutboundMessage( - channel="telegram", chat_id="c1", content="status ok" - ) + with patch.object(loop, "_dispatch", new_callable=AsyncMock) as mock_dispatch: await bus.publish_inbound(msg) loop._running = True @@ -89,9 +91,9 @@ class TestRestartCommand: except asyncio.CancelledError: pass - mock_status.assert_called_once() + mock_dispatch.assert_not_called() out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0) - assert out.content == "status ok" + assert "nanobot" in out.content.lower() or "Model" in out.content @pytest.mark.asyncio async def test_run_propagates_external_cancellation(self): diff --git a/tests/test_task_cancel.py b/tests/test_task_cancel.py index 5bc2ea9..c80d4b5 100644 --- a/tests/test_task_cancel.py +++ b/tests/test_task_cancel.py @@ -31,16 +31,20 @@ class TestHandleStop: @pytest.mark.asyncio async def test_stop_no_active_task(self): from nanobot.bus.events import InboundMessage + from nanobot.command.builtin import cmd_stop + from nanobot.command.router import CommandContext 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) + ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/stop", loop=loop) + out = await cmd_stop(ctx) assert "No active task" in out.content @pytest.mark.asyncio async def test_stop_cancels_active_task(self): from nanobot.bus.events import InboundMessage + from nanobot.command.builtin import cmd_stop + from nanobot.command.router import CommandContext loop, bus = _make_loop() cancelled = asyncio.Event() @@ -57,15 +61,17 @@ class TestHandleStop: loop._active_tasks["test:c1"] = [task] msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="/stop") - await loop._handle_stop(msg) + ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/stop", loop=loop) + out = await cmd_stop(ctx) assert cancelled.is_set() - out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0) assert "stopped" in out.content.lower() @pytest.mark.asyncio async def test_stop_cancels_multiple_tasks(self): from nanobot.bus.events import InboundMessage + from nanobot.command.builtin import cmd_stop + from nanobot.command.router import CommandContext loop, bus = _make_loop() events = [asyncio.Event(), asyncio.Event()] @@ -82,10 +88,10 @@ class TestHandleStop: loop._active_tasks["test:c1"] = tasks msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="/stop") - await loop._handle_stop(msg) + ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/stop", loop=loop) + out = await cmd_stop(ctx) 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