From 15f7d15108457d4fd2e9d2e6a79f6740df6402ad Mon Sep 17 00:00:00 2001 From: Hua Date: Tue, 24 Mar 2026 13:56:02 +0800 Subject: [PATCH] refactor(agent): split slash commands and harden skill sync --- AGENTS.md | 6 +- README.md | 16 +- nanobot/agent/commands/__init__.py | 17 ++ nanobot/agent/commands/language.py | 62 ++++ nanobot/agent/commands/mcp.py | 64 ++++ nanobot/agent/commands/persona.py | 76 +++++ nanobot/agent/commands/router.py | 86 ++++++ nanobot/agent/commands/skill.py | 434 +++++++++++++++++++++++++++ nanobot/agent/commands/system.py | 75 +++++ nanobot/agent/loop.py | 464 ++++++----------------------- nanobot/locales/en.json | 13 + nanobot/locales/zh.json | 13 + nanobot/skills/clawhub/SKILL.md | 29 +- tests/test_consolidate_offset.py | 3 +- tests/test_restart_command.py | 2 +- tests/test_skill_commands.py | 202 ++++++++++--- 16 files changed, 1140 insertions(+), 422 deletions(-) create mode 100644 nanobot/agent/commands/__init__.py create mode 100644 nanobot/agent/commands/language.py create mode 100644 nanobot/agent/commands/mcp.py create mode 100644 nanobot/agent/commands/persona.py create mode 100644 nanobot/agent/commands/router.py create mode 100644 nanobot/agent/commands/skill.py create mode 100644 nanobot/agent/commands/system.py diff --git a/AGENTS.md b/AGENTS.md index 781d05a..98207ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,8 +40,10 @@ Do not commit real API keys, tokens, chat logs, or workspace data. Keep local se - `channels.voiceReply` currently adds TTS attachments on supported outbound channels such as Telegram, and QQ when the configured TTS endpoint returns `silk`. Preserve plain-text fallback when QQ voice requirements are not met. - Voice replies should follow the active session persona. Build TTS style instructions from the resolved persona's prompt files, and allow optional persona-local overrides from `VOICE.json` under the persona workspace (`/VOICE.json` for default, `/personas//VOICE.json` for custom personas). - `channels.voiceReply.url` may override the TTS endpoint independently of the chat model provider. When omitted, fall back to the active conversation provider URL. Keep `apiBase` accepted as a compatibility alias. -- `/skill` shells out to `npx clawhub@latest`; it requires Node.js/`npx` at runtime. -- `/skill uninstall` runs in a non-interactive context, so keep passing `--yes` when shelling out to ClawHub. +- `/skill search` queries `https://lightmake.site/api/skills` directly with SkillHub-compatible query params (`page`, `pageSize`, `sortBy`, `order`, `keyword`) and does not require Node.js. +- `/skill` shells out to `npx clawhub@latest` for `install`, `list`, and `update`; those subcommands still require Node.js/`npx` at runtime. +- Keep ClawHub global options first when shelling out: `--workdir --no-input ...`. +- `/skill uninstall` is local workspace cleanup, not a ClawHub subprocess call. Remove `/skills/` and best-effort prune `/.clawhub/lock.json`. - Treat empty `/skill search` output as a user-visible "no results" case rather than a silent success. Surface npm/registry failures directly to the user. - Never hardcode `~/.nanobot/workspace` for skill installation or lookup. Use the active runtime workspace from config or `--workspace`. - Workspace skills in `/skills/` take precedence over built-in skills with the same directory name. diff --git a/README.md b/README.md index ad34661..47f0e1b 100644 --- a/README.md +++ b/README.md @@ -1603,7 +1603,7 @@ These commands are available inside chats handled by `nanobot agent` or `nanobot | `/persona set ` | Switch persona and start a new session | | `/skill search ` | Search public skills on ClawHub | | `/skill install ` | Install a ClawHub skill into the active workspace | -| `/skill uninstall ` | Remove a ClawHub-managed skill from the active workspace | +| `/skill uninstall ` | Remove a locally installed workspace skill from the active workspace | | `/skill list` | List ClawHub-managed skills in the active workspace | | `/skill update` | Update all ClawHub-managed skills in the active workspace | | `/mcp [list]` | List configured MCP servers and registered MCP tools | @@ -1616,10 +1616,20 @@ These commands are available inside chats handled by `nanobot agent` or `nanobot `~/.nanobot/workspace` path. If you start nanobot with `--workspace`, skill install/uninstall/list/update operate on that workspace's `skills/` directory. +`/skill search` queries the live ClawHub registry API directly at +`https://lightmake.site/api/skills` using the same sort order as the SkillHub web UI, so search +does not depend on `npm` or `npx`. + +For `install`, `list`, and `update`, nanobot still shells out to `npx clawhub@latest` +using ClawHub global options first: `--workdir --no-input ...`. `/skill uninstall` +removes the local `/skills/` directory directly and best-effort prunes +`/.clawhub/lock.json`, because current ClawHub docs do not document an uninstall +subcommand. + `/skill search` can legitimately return no matches. In that case nanobot now replies with a clear "no skills found" message instead of leaving the channel on a transient searching state. -If `npx clawhub@latest` cannot reach the npm registry, nanobot also surfaces the registry/network -error directly so the failure is visible to the user. +If the ClawHub registry API or `npx clawhub@latest` cannot be reached, nanobot also surfaces the +underlying network or HTTP error directly so the failure is visible to the user.
Heartbeat (Periodic Tasks) diff --git a/nanobot/agent/commands/__init__.py b/nanobot/agent/commands/__init__.py new file mode 100644 index 0000000..8ec04f2 --- /dev/null +++ b/nanobot/agent/commands/__init__.py @@ -0,0 +1,17 @@ +"""Command handlers for AgentLoop slash commands.""" + +from nanobot.agent.commands.language import LanguageCommandHandler +from nanobot.agent.commands.mcp import MCPCommandHandler +from nanobot.agent.commands.persona import PersonaCommandHandler +from nanobot.agent.commands.router import build_agent_command_router +from nanobot.agent.commands.skill import SkillCommandHandler +from nanobot.agent.commands.system import SystemCommandHandler + +__all__ = [ + "LanguageCommandHandler", + "MCPCommandHandler", + "PersonaCommandHandler", + "SkillCommandHandler", + "SystemCommandHandler", + "build_agent_command_router", +] diff --git a/nanobot/agent/commands/language.py b/nanobot/agent/commands/language.py new file mode 100644 index 0000000..74cb406 --- /dev/null +++ b/nanobot/agent/commands/language.py @@ -0,0 +1,62 @@ +"""Language command helpers for AgentLoop.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from nanobot.agent.i18n import language_label, list_languages, normalize_language_code, text +from nanobot.bus.events import InboundMessage, OutboundMessage + +if TYPE_CHECKING: + from nanobot.agent.loop import AgentLoop + from nanobot.session.manager import Session + + +class LanguageCommandHandler: + """Encapsulates `/lang` subcommand behavior for AgentLoop.""" + + def __init__(self, loop: AgentLoop) -> None: + self.loop = loop + + @staticmethod + def _response(msg: InboundMessage, content: str) -> OutboundMessage: + return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=content) + + def current(self, msg: InboundMessage, session: Session) -> OutboundMessage: + current = self.loop._get_session_language(session) + return self._response( + msg, + text(current, "current_language", language_name=language_label(current, current)), + ) + + def list(self, msg: InboundMessage, session: Session) -> OutboundMessage: + current = self.loop._get_session_language(session) + items = "\n".join( + f"- {language_label(code, current)}" + + (f" ({text(current, 'current_marker')})" if code == current else "") + for code in list_languages() + ) + return self._response(msg, text(current, "available_languages", items=items)) + + def set(self, msg: InboundMessage, session: Session, target_raw: str) -> OutboundMessage: + current = self.loop._get_session_language(session) + target = normalize_language_code(target_raw) + if target is None: + languages = ", ".join(language_label(code, current) for code in list_languages()) + return self._response( + msg, + text(current, "unknown_language", name=target_raw, languages=languages), + ) + + if target == current: + return self._response( + msg, + text(current, "language_already_active", language_name=language_label(target, current)), + ) + + self.loop._set_session_language(session, target) + self.loop.sessions.save(session) + return self._response( + msg, + text(target, "switched_language", language_name=language_label(target, target)), + ) diff --git a/nanobot/agent/commands/mcp.py b/nanobot/agent/commands/mcp.py new file mode 100644 index 0000000..eecba04 --- /dev/null +++ b/nanobot/agent/commands/mcp.py @@ -0,0 +1,64 @@ +"""MCP command helpers for AgentLoop.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from nanobot.agent.i18n import text +from nanobot.bus.events import InboundMessage, OutboundMessage + +if TYPE_CHECKING: + from nanobot.agent.loop import AgentLoop + + +class MCPCommandHandler: + """Encapsulates `/mcp` subcommand behavior for AgentLoop.""" + + def __init__(self, loop: AgentLoop) -> None: + self.loop = loop + + @staticmethod + def _response(msg: InboundMessage, content: str) -> OutboundMessage: + return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=content) + + def _group_mcp_tool_names(self) -> dict[str, list[str]]: + """Group registered MCP tool names by configured server name.""" + grouped = {name: [] for name in self.loop._mcp_servers} + server_names = sorted(self.loop._mcp_servers, key=len, reverse=True) + + for tool_name in self.loop.tools.tool_names: + if not tool_name.startswith("mcp_"): + continue + + for server_name in server_names: + prefix = f"mcp_{server_name}_" + if tool_name.startswith(prefix): + grouped[server_name].append(tool_name.removeprefix(prefix)) + break + + return {name: sorted(tools) for name, tools in grouped.items()} + + async def list(self, msg: InboundMessage, language: str) -> OutboundMessage: + await self.loop._reload_mcp_servers_if_needed() + + if not self.loop._mcp_servers: + return self._response(msg, text(language, "mcp_no_servers")) + + await self.loop._connect_mcp() + + server_lines = "\n".join(f"- {name}" for name in self.loop._mcp_servers) + sections = [text(language, "mcp_servers_list", items=server_lines)] + + grouped_tools = self._group_mcp_tool_names() + tool_lines = "\n".join( + f"- {server}: {', '.join(tools)}" + for server, tools in grouped_tools.items() + if tools + ) + sections.append( + text(language, "mcp_tools_list", items=tool_lines) + if tool_lines + else text(language, "mcp_no_tools") + ) + + return self._response(msg, "\n\n".join(sections)) diff --git a/nanobot/agent/commands/persona.py b/nanobot/agent/commands/persona.py new file mode 100644 index 0000000..75b1dfb --- /dev/null +++ b/nanobot/agent/commands/persona.py @@ -0,0 +1,76 @@ +"""Persona command helpers for AgentLoop.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from loguru import logger + +from nanobot.agent.i18n import text +from nanobot.bus.events import InboundMessage, OutboundMessage + +if TYPE_CHECKING: + from nanobot.agent.loop import AgentLoop + from nanobot.session.manager import Session + + +class PersonaCommandHandler: + """Encapsulates `/persona` subcommand behavior for AgentLoop.""" + + def __init__(self, loop: AgentLoop) -> None: + self.loop = loop + + @staticmethod + def _response(msg: InboundMessage, content: str) -> OutboundMessage: + return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=content) + + def current(self, msg: InboundMessage, session: Session) -> OutboundMessage: + language = self.loop._get_session_language(session) + current = self.loop._get_session_persona(session) + return self._response(msg, text(language, "current_persona", persona=current)) + + def list(self, msg: InboundMessage, session: Session) -> OutboundMessage: + language = self.loop._get_session_language(session) + current = self.loop._get_session_persona(session) + marker = text(language, "current_marker") + personas = [ + f"{name} ({marker})" if name == current else name + for name in self.loop.context.list_personas() + ] + return self._response( + msg, + text(language, "available_personas", items="\n".join(f"- {name}" for name in personas)), + ) + + async def set(self, msg: InboundMessage, session: Session, target_raw: str) -> OutboundMessage: + language = self.loop._get_session_language(session) + target = self.loop.context.find_persona(target_raw) + if target is None: + personas = ", ".join(self.loop.context.list_personas()) + return self._response( + msg, + text( + language, + "unknown_persona", + name=target_raw, + personas=personas, + path=self.loop.workspace / "personas" / target_raw, + ), + ) + + current = self.loop._get_session_persona(session) + if target == current: + return self._response(msg, text(language, "persona_already_active", persona=target)) + + try: + if not await self.loop.memory_consolidator.archive_unconsolidated(session): + return self._response(msg, text(language, "memory_archival_failed_persona")) + except Exception: + logger.exception("/persona archival failed for {}", session.key) + return self._response(msg, text(language, "memory_archival_failed_persona")) + + session.clear() + self.loop._set_session_persona(session, target) + self.loop.sessions.save(session) + self.loop.sessions.invalidate(session.key) + return self._response(msg, text(language, "switched_persona", persona=target)) diff --git a/nanobot/agent/commands/router.py b/nanobot/agent/commands/router.py new file mode 100644 index 0000000..fcebd8e --- /dev/null +++ b/nanobot/agent/commands/router.py @@ -0,0 +1,86 @@ +"""AgentLoop slash-command router registration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from nanobot.command.router import CommandContext, CommandRouter + +if TYPE_CHECKING: + from nanobot.bus.events import OutboundMessage + + +def _session(ctx: CommandContext): + return ctx.session or ctx.loop.sessions.get_or_create(ctx.key) + + +async def _cmd_status(ctx: CommandContext) -> OutboundMessage: + session = _session(ctx) + return ctx.loop._system_commands.status(ctx.msg, session) + + +async def _cmd_new(ctx: CommandContext) -> OutboundMessage: + session = _session(ctx) + language = ctx.loop._get_session_language(session) + return ctx.loop._system_commands.new_session(ctx.msg, session, language) + + +async def _cmd_help(ctx: CommandContext) -> OutboundMessage: + session = _session(ctx) + language = ctx.loop._get_session_language(session) + return ctx.loop._system_commands.help(ctx.msg, language) + + +async def _cmd_lang(ctx: CommandContext): + return await ctx.loop._handle_language_command(ctx.msg, _session(ctx)) + + +async def _cmd_persona(ctx: CommandContext): + return await ctx.loop._handle_persona_command(ctx.msg, _session(ctx)) + + +async def _cmd_skill(ctx: CommandContext): + return await ctx.loop._handle_skill_command(ctx.msg, _session(ctx)) + + +async def _cmd_mcp(ctx: CommandContext): + return await ctx.loop._handle_mcp_command(ctx.msg, _session(ctx)) + + +async def _cmd_stop_priority(ctx: CommandContext): + await ctx.loop._handle_stop(ctx.msg) + return None + + +async def _cmd_restart_priority(ctx: CommandContext): + await ctx.loop._handle_restart(ctx.msg) + return None + + +def build_agent_command_router() -> CommandRouter: + """Create the slash-command router used by AgentLoop.""" + router = CommandRouter() + + router.priority("/stop", _cmd_stop_priority) + router.priority("/restart", _cmd_restart_priority) + router.priority("/status", _cmd_status) + + router.exact("/new", _cmd_new) + router.exact("/status", _cmd_status) + router.exact("/help", _cmd_help) + + router.exact("/lang", _cmd_lang) + router.exact("/language", _cmd_lang) + router.prefix("/lang ", _cmd_lang) + router.prefix("/language ", _cmd_lang) + + router.exact("/persona", _cmd_persona) + router.prefix("/persona ", _cmd_persona) + + router.exact("/skill", _cmd_skill) + router.prefix("/skill ", _cmd_skill) + + router.exact("/mcp", _cmd_mcp) + router.prefix("/mcp ", _cmd_mcp) + + return router diff --git a/nanobot/agent/commands/skill.py b/nanobot/agent/commands/skill.py new file mode 100644 index 0000000..afd5c58 --- /dev/null +++ b/nanobot/agent/commands/skill.py @@ -0,0 +1,434 @@ +"""Skill command helpers for AgentLoop.""" + +from __future__ import annotations + +import asyncio +import json +import os +import shutil +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import httpx +from loguru import logger + +from nanobot.agent.i18n import text +from nanobot.bus.events import InboundMessage, OutboundMessage +from nanobot.utils.helpers import ensure_dir + +if TYPE_CHECKING: + from nanobot.agent.loop import AgentLoop + + +class SkillCommandHandler: + """Encapsulates `/skill` subcommand behavior for AgentLoop.""" + + def __init__(self, loop: AgentLoop) -> None: + self.loop = loop + + @staticmethod + def _response(msg: InboundMessage, content: str) -> OutboundMessage: + return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=content) + + @staticmethod + def _decode_subprocess_output(data: bytes) -> str: + """Decode subprocess output conservatively for CLI surfacing.""" + return data.decode("utf-8", errors="replace").strip() + + def _is_clawhub_network_error(self, output: str) -> bool: + lowered = output.lower() + return any(marker in lowered for marker in self.loop._CLAWHUB_NETWORK_ERROR_MARKERS) + + def _format_clawhub_error(self, language: str, code: int, output: str) -> str: + if output and self._is_clawhub_network_error(output): + return "\n\n".join([text(language, "skill_command_network_failed"), output]) + return output or text(language, "skill_command_failed", code=code) + + @staticmethod + def _clawhub_search_headers(language: str) -> dict[str, str]: + accept_language = "zh-CN,zh;q=0.9,en;q=0.8" if language.startswith("zh") else "en-US,en;q=0.9" + return { + "accept": "*/*", + "accept-language": accept_language, + "origin": "https://skillhub.tencent.com", + "referer": "https://skillhub.tencent.com/", + } + + def _format_clawhub_search_results( + self, + language: str, + query: str, + skills: list[dict[str, Any]], + total: int, + ) -> str: + blocks = [ + text( + language, + "skill_search_results_header", + query=query, + total=total, + count=len(skills), + ) + ] + description_key = "description_zh" if language.startswith("zh") else "description" + for index, skill in enumerate(skills, start=1): + name = str(skill.get("name") or skill.get("slug") or f"skill-{index}") + slug = str(skill.get("slug") or "-") + owner = str(skill.get("ownerName") or "-") + installs = str(skill.get("installs") or 0) + stars = str(skill.get("stars") or 0) + version = str(skill.get("version") or "-") + description = str( + skill.get(description_key) or skill.get("description") or skill.get("description_zh") or "" + ).strip() + homepage = str(skill.get("homepage") or "").strip() + lines = [ + f"{index}. {name}", + text( + language, + "skill_search_result_meta", + slug=slug, + owner=owner, + installs=installs, + stars=stars, + version=version, + ), + ] + if description: + lines.append(description) + if homepage: + lines.append(homepage) + blocks.append("\n".join(lines)) + return "\n\n".join(blocks) + + async def _search_clawhub( + self, + language: str, + query: str, + ) -> tuple[int, str]: + params = { + "page": "1", + "pageSize": str(self.loop._CLAWHUB_SEARCH_LIMIT), + "sortBy": "score", + "order": "desc", + "keyword": query, + } + try: + async with httpx.AsyncClient( + proxy=self.loop.web_proxy, + follow_redirects=True, + timeout=self.loop._CLAWHUB_SEARCH_TIMEOUT_SECONDS, + ) as client: + response = await client.get( + self.loop._CLAWHUB_SEARCH_API_URL, + params=params, + headers=self._clawhub_search_headers(language), + ) + response.raise_for_status() + except httpx.TimeoutException: + return 124, text(language, "skill_search_timeout") + except httpx.HTTPStatusError as exc: + details = exc.response.text.strip() + message = text(language, "skill_search_failed_status", status=exc.response.status_code) + return exc.response.status_code, "\n\n".join(part for part in [message, details] if part) + except httpx.RequestError as exc: + return 1, "\n\n".join([text(language, "skill_search_request_failed"), str(exc)]) + + try: + payload = response.json() + except ValueError: + return 1, text(language, "skill_search_invalid_response") + + if not isinstance(payload, dict): + return 1, text(language, "skill_search_invalid_response") + + if payload.get("code") != 0: + details = str(payload.get("message") or "").strip() + return 1, "\n\n".join( + part for part in [text(language, "skill_search_failed"), details] if part + ) + + data = payload.get("data") + if not isinstance(data, dict): + return 1, text(language, "skill_search_invalid_response") + + skills = data.get("skills") + if not isinstance(skills, list): + return 1, text(language, "skill_search_invalid_response") + + total = data.get("total") + if not isinstance(total, int): + total = len(skills) + + if not skills: + return 0, "" + + return 0, self._format_clawhub_search_results(language, query, skills, total) + + def _clawhub_env(self) -> dict[str, str]: + """Configure npm so ClawHub fails fast and uses a writable cache directory.""" + env = os.environ.copy() + env.setdefault("NO_COLOR", "1") + env.setdefault("FORCE_COLOR", "0") + env.setdefault("npm_config_cache", str(ensure_dir(self.loop._clawhub_npm_cache_dir))) + env.setdefault("npm_config_update_notifier", "false") + env.setdefault("npm_config_audit", "false") + env.setdefault("npm_config_fund", "false") + env.setdefault("npm_config_fetch_retries", "0") + env.setdefault("npm_config_fetch_timeout", "5000") + env.setdefault("npm_config_fetch_retry_mintimeout", "1000") + env.setdefault("npm_config_fetch_retry_maxtimeout", "5000") + return env + + def _is_clawhub_cache_error(self, output: str) -> bool: + lowered = output.lower() + return any(marker in lowered for marker in self.loop._CLAWHUB_CACHE_ERROR_MARKERS) and ( + "_npx/" in lowered or "_npx\\" in lowered + ) + + @staticmethod + def _clear_clawhub_exec_cache(env: dict[str, str]) -> None: + """Clear npm's temporary exec installs without wiping the shared tarball cache.""" + cache_root = env.get("npm_config_cache") + if not cache_root: + return + shutil.rmtree(Path(cache_root) / "_npx", ignore_errors=True) + + async def _run_clawhub_once( + self, + npx: str, + env: dict[str, str], + *args: str, + timeout_seconds: int | None = None, + ) -> tuple[int, str]: + """Run one ClawHub subprocess attempt and return (exit_code, combined_output).""" + proc = None + try: + proc = await asyncio.create_subprocess_exec( + npx, + "--yes", + "clawhub@latest", + *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=env, + ) + stdout, stderr = await asyncio.wait_for( + proc.communicate(), + timeout=timeout_seconds or self.loop._CLAWHUB_TIMEOUT_SECONDS, + ) + except FileNotFoundError: + raise + except asyncio.TimeoutError: + if proc is not None and proc.returncode is None: + proc.kill() + await proc.communicate() + raise + except asyncio.CancelledError: + if proc is not None and proc.returncode is None: + proc.kill() + await proc.communicate() + raise + + output_parts = [ + self._decode_subprocess_output(stdout), + self._decode_subprocess_output(stderr), + ] + output = "\n".join(part for part in output_parts if part).strip() + return proc.returncode or 0, output + + @staticmethod + def _clawhub_args(workspace: str, *args: str) -> tuple[str, ...]: + """Build ClawHub CLI args with global options first for consistent parsing.""" + return ("--workdir", workspace, "--no-input", *args) + + @staticmethod + def _is_valid_skill_slug(slug: str) -> bool: + """Validate a workspace skill slug for local install/remove operations.""" + return bool(slug) and slug not in {".", ".."} and "/" not in slug and "\\" not in slug + + def _prune_clawhub_lockfile(self, slug: str) -> bool: + """Best-effort removal of a skill entry from the local ClawHub lockfile.""" + lock_path = self.loop.workspace / ".clawhub" / "lock.json" + if not lock_path.exists(): + return False + + data = json.loads(lock_path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + return False + + changed = False + skills = data.get("skills") + if isinstance(skills, dict) and slug in skills: + del skills[slug] + changed = True + elif isinstance(skills, list): + filtered = [ + item + for item in skills + if not ( + item == slug + or (isinstance(item, dict) and (item.get("slug") == slug or item.get("name") == slug)) + ) + ] + if len(filtered) != len(skills): + data["skills"] = filtered + changed = True + + if changed: + lock_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return changed + + async def _run_clawhub( + self, language: str, *args: str, timeout_seconds: int | None = None, + ) -> tuple[int, str]: + """Run the ClawHub CLI and return (exit_code, combined_output).""" + npx = shutil.which("npx") + if not npx: + return 127, text(language, "skill_npx_missing") + + env = self._clawhub_env() + + try: + async with self.loop._clawhub_lock: + code, output = await self._run_clawhub_once( + npx, + env, + *args, + timeout_seconds=timeout_seconds, + ) + if code != 0 and self._is_clawhub_cache_error(output): + logger.warning( + "Retrying ClawHub command after clearing npm exec cache at {}", + env["npm_config_cache"], + ) + self._clear_clawhub_exec_cache(env) + code, output = await self._run_clawhub_once( + npx, + env, + *args, + timeout_seconds=timeout_seconds, + ) + except FileNotFoundError: + return 127, text(language, "skill_npx_missing") + except asyncio.TimeoutError: + return 124, text(language, "skill_command_timeout") + except asyncio.CancelledError: + raise + return code, output + + def _format_skill_command_success( + self, + language: str, + subcommand: str, + output: str, + *, + include_workspace_note: bool = False, + ) -> str: + notes: list[str] = [] + if output: + notes.append(output) + if include_workspace_note: + notes.append(text(language, "skill_applied_to_workspace", workspace=self.loop.workspace)) + return "\n\n".join(notes) if notes else text(language, "skill_command_completed", command=subcommand) + + async def _run_skill_clawhub_command( + self, + msg: InboundMessage, + language: str, + subcommand: str, + *args: str, + timeout_seconds: int | None = None, + include_workspace_note: bool = False, + ) -> OutboundMessage: + code, output = await self._run_clawhub( + language, + *self._clawhub_args(str(self.loop.workspace), *args), + timeout_seconds=timeout_seconds, + ) + if code != 0: + return self._response(msg, self._format_clawhub_error(language, code, output)) + return self._response( + msg, + self._format_skill_command_success( + language, + subcommand, + output, + include_workspace_note=include_workspace_note, + ), + ) + + async def search(self, msg: InboundMessage, language: str, query: str) -> OutboundMessage: + code, output = await self._search_clawhub(language, query) + if code != 0: + return self._response(msg, output or text(language, "skill_search_failed")) + if not output: + return self._response(msg, text(language, "skill_search_no_results", query=query)) + return self._response(msg, output) + + async def install(self, msg: InboundMessage, language: str, slug: str) -> OutboundMessage: + return await self._run_skill_clawhub_command( + msg, + language, + "install", + "install", + slug, + timeout_seconds=self.loop._CLAWHUB_INSTALL_TIMEOUT_SECONDS, + include_workspace_note=True, + ) + + async def uninstall(self, msg: InboundMessage, language: str, slug: str) -> OutboundMessage: + if not self._is_valid_skill_slug(slug): + return self._response(msg, text(language, "skill_invalid_slug", slug=slug)) + + skill_dir = self.loop.workspace / "skills" / slug + if not skill_dir.is_dir(): + return self._response( + msg, + text(language, "skill_uninstall_not_found", slug=slug, path=skill_dir), + ) + + try: + shutil.rmtree(skill_dir) + except OSError: + logger.exception("Failed to remove workspace skill {}", skill_dir) + return self._response( + msg, + text(language, "skill_uninstall_failed", slug=slug, path=skill_dir), + ) + + notes = [text(language, "skill_uninstalled_local", slug=slug, path=skill_dir)] + try: + if self._prune_clawhub_lockfile(slug): + notes.append( + text( + language, + "skill_lockfile_pruned", + path=self.loop.workspace / ".clawhub" / "lock.json", + ) + ) + except (OSError, ValueError, TypeError): + logger.exception("Failed to prune ClawHub lockfile for {}", slug) + notes.append( + text( + language, + "skill_lockfile_cleanup_failed", + path=self.loop.workspace / ".clawhub" / "lock.json", + ) + ) + + return self._response(msg, "\n\n".join(notes)) + + async def list(self, msg: InboundMessage, language: str) -> OutboundMessage: + return await self._run_skill_clawhub_command(msg, language, "list", "list") + + async def update(self, msg: InboundMessage, language: str) -> OutboundMessage: + return await self._run_skill_clawhub_command( + msg, + language, + "update", + "update", + "--all", + timeout_seconds=self.loop._CLAWHUB_INSTALL_TIMEOUT_SECONDS, + include_workspace_note=True, + ) diff --git a/nanobot/agent/commands/system.py b/nanobot/agent/commands/system.py new file mode 100644 index 0000000..cc836b2 --- /dev/null +++ b/nanobot/agent/commands/system.py @@ -0,0 +1,75 @@ +"""Lightweight system command helpers for AgentLoop.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from nanobot import __version__ +from nanobot.agent.i18n import help_lines, text +from nanobot.bus.events import InboundMessage, OutboundMessage +from nanobot.utils.helpers import build_status_content + +if TYPE_CHECKING: + from nanobot.agent.loop import AgentLoop + from nanobot.session.manager import Session + + +class SystemCommandHandler: + """Encapsulates lightweight `/new`, `/help`, and `/status` behavior for AgentLoop.""" + + def __init__(self, loop: AgentLoop) -> None: + self.loop = loop + + @staticmethod + def _response( + msg: InboundMessage, + content: str, + *, + metadata: dict[str, str] | None = None, + ) -> OutboundMessage: + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=content, + metadata=metadata, + ) + + def help(self, msg: InboundMessage, language: str) -> OutboundMessage: + return self._response( + msg, + "\n".join(help_lines(language)), + metadata={"render_as": "text"}, + ) + + def new_session(self, msg: InboundMessage, session: Session, language: str) -> OutboundMessage: + snapshot = session.messages[session.last_consolidated:] + session.clear() + self.loop.sessions.save(session) + self.loop.sessions.invalidate(session.key) + + if snapshot: + self.loop._schedule_background(self.loop.memory_consolidator.archive_messages(session, snapshot)) + + return self._response(msg, text(language, "new_session_started")) + + def status(self, msg: InboundMessage, session: Session) -> OutboundMessage: + ctx_est = 0 + try: + ctx_est, _ = self.loop.memory_consolidator.estimate_session_prompt_tokens(session) + except Exception: + pass + if ctx_est <= 0: + ctx_est = self.loop._last_usage.get("prompt_tokens", 0) + return self._response( + msg, + build_status_content( + version=__version__, + model=self.loop.model, + start_time=self.loop._start_time, + last_usage=self.loop._last_usage, + context_window_tokens=self.loop.context_window_tokens, + session_msg_count=len(session.get_history(max_messages=0)), + context_tokens_estimate=ctx_est, + ), + metadata={"render_as": "text"}, + ) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index f43cbf0..543c3cb 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -5,7 +5,6 @@ from __future__ import annotations import asyncio import json import os -import shutil import sys import tempfile import time @@ -15,14 +14,17 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable from loguru import logger -from nanobot import __version__ +from nanobot.agent.commands import ( + LanguageCommandHandler, + MCPCommandHandler, + PersonaCommandHandler, + SkillCommandHandler, + SystemCommandHandler, + build_agent_command_router, +) from nanobot.agent.context import ContextBuilder from nanobot.agent.i18n import ( DEFAULT_LANGUAGE, - help_lines, - language_label, - list_languages, - normalize_language_code, resolve_language, text, ) @@ -39,10 +41,11 @@ from nanobot.agent.tools.spawn import SpawnTool from nanobot.agent.tools.web import WebFetchTool, WebSearchTool from nanobot.bus.events import InboundMessage, OutboundMessage from nanobot.bus.queue import MessageBus +from nanobot.command.router import CommandContext from nanobot.providers.base import LLMProvider from nanobot.providers.speech import OpenAISpeechProvider from nanobot.session.manager import Session, SessionManager -from nanobot.utils.helpers import build_status_content, ensure_dir, safe_filename +from nanobot.utils.helpers import ensure_dir, safe_filename if TYPE_CHECKING: from nanobot.config.schema import ChannelsConfig, ExecToolConfig @@ -74,6 +77,14 @@ class AgentLoop: "network request failed", "registry.npmjs.org", ) + _CLAWHUB_CACHE_ERROR_MARKERS = ( + "err_module_not_found", + "cannot find module", + "cannot find package", + ) + _CLAWHUB_SEARCH_API_URL = "https://lightmake.site/api/skills" + _CLAWHUB_SEARCH_TIMEOUT_SECONDS = 15.0 + _CLAWHUB_SEARCH_LIMIT = 5 _CLAWHUB_NPM_CACHE_DIR = Path(tempfile.gettempdir()) / "nanobot-npm-cache" _PREFLIGHT_CONSOLIDATION_BUDGET_SECONDS = 1.5 @@ -117,6 +128,14 @@ class AgentLoop: self.restrict_to_workspace = restrict_to_workspace self._start_time = time.time() self._last_usage: dict[str, int] = {} + self._clawhub_lock = asyncio.Lock() + self._clawhub_npm_cache_dir = self._CLAWHUB_NPM_CACHE_DIR / str(os.getpid()) + self._language_commands = LanguageCommandHandler(self) + self._mcp_commands = MCPCommandHandler(self) + self._persona_commands = PersonaCommandHandler(self) + self._skill_commands = SkillCommandHandler(self) + self._system_commands = SystemCommandHandler(self) + self._command_router = build_agent_command_router() self.context = ContextBuilder(workspace) self.sessions = session_manager or SessionManager(workspace) @@ -164,12 +183,6 @@ class AgentLoop: ) self._register_default_tools() - @staticmethod - def _command_name(content: str) -> str: - """Return the normalized slash command name.""" - parts = content.strip().split(None, 1) - return parts[0].lower() if parts else "" - def _get_session_persona(self, session: Session) -> str: """Return the active persona name for a session.""" return self.context.resolve_persona(session.metadata.get("persona")) @@ -214,23 +227,6 @@ class AgentLoop: """Return MCP command help text.""" return text(language, "mcp_usage") - def _group_mcp_tool_names(self) -> dict[str, list[str]]: - """Group registered MCP tool names by configured server name.""" - grouped = {name: [] for name in self._mcp_servers} - server_names = sorted(self._mcp_servers, key=len, reverse=True) - - for tool_name in self.tools.tool_names: - if not tool_name.startswith("mcp_"): - continue - - for server_name in server_names: - prefix = f"mcp_{server_name}_" - if tool_name.startswith(prefix): - grouped[server_name].append(tool_name.removeprefix(prefix)) - break - - return {name: sorted(tools) for name, tools in grouped.items()} - def _remove_registered_mcp_tools(self) -> None: """Remove all dynamically registered MCP tools from the registry.""" for tool_name in list(self.tools.tool_names): @@ -368,176 +364,86 @@ class AgentLoop: await self._reload_runtime_config_if_needed(force=force) @staticmethod - def _decode_subprocess_output(data: bytes) -> str: - """Decode subprocess output conservatively for CLI surfacing.""" - return data.decode("utf-8", errors="replace").strip() + def _skill_subcommand(parts: list[str]) -> str | None: + if len(parts) < 2: + return None + return parts[1].lower() - @classmethod - def _is_clawhub_network_error(cls, output: str) -> bool: - lowered = output.lower() - return any(marker in lowered for marker in cls._CLAWHUB_NETWORK_ERROR_MARKERS) + @staticmethod + def _skill_search_query(content: str) -> str | None: + query_parts = content.strip().split(None, 2) + if len(query_parts) < 3: + return None + query = query_parts[2].strip() + return query or None - def _format_clawhub_error(self, language: str, code: int, output: str) -> str: - if output and self._is_clawhub_network_error(output): - return "\n\n".join([text(language, "skill_command_network_failed"), output]) - return output or text(language, "skill_command_failed", code=code) + @staticmethod + def _skill_argument(parts: list[str]) -> str | None: + if len(parts) < 3: + return None + value = parts[2].strip() + return value or None - def _clawhub_env(self) -> dict[str, str]: - """Configure npm so ClawHub fails fast and uses a writable cache directory.""" - env = os.environ.copy() - env.setdefault("NO_COLOR", "1") - env.setdefault("FORCE_COLOR", "0") - env.setdefault("npm_config_cache", str(self._CLAWHUB_NPM_CACHE_DIR)) - env.setdefault("npm_config_update_notifier", "false") - env.setdefault("npm_config_audit", "false") - env.setdefault("npm_config_fund", "false") - env.setdefault("npm_config_fetch_retries", "0") - env.setdefault("npm_config_fetch_timeout", "5000") - env.setdefault("npm_config_fetch_retry_mintimeout", "1000") - env.setdefault("npm_config_fetch_retry_maxtimeout", "5000") - return env - - async def _run_clawhub( - self, language: str, *args: str, timeout_seconds: int | None = None, - ) -> tuple[int, str]: - """Run the ClawHub CLI and return (exit_code, combined_output).""" - npx = shutil.which("npx") - if not npx: - return 127, text(language, "skill_npx_missing") - - env = self._clawhub_env() - - proc = None - try: - proc = await asyncio.create_subprocess_exec( - npx, - "--yes", - "clawhub@latest", - *args, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - env=env, - ) - stdout, stderr = await asyncio.wait_for( - proc.communicate(), timeout=timeout_seconds or self._CLAWHUB_TIMEOUT_SECONDS, - ) - except FileNotFoundError: - return 127, text(language, "skill_npx_missing") - except asyncio.TimeoutError: - if proc is not None and proc.returncode is None: - proc.kill() - await proc.communicate() - return 124, text(language, "skill_command_timeout") - except asyncio.CancelledError: - if proc is not None and proc.returncode is None: - proc.kill() - await proc.communicate() - raise - - output_parts = [ - self._decode_subprocess_output(stdout), - self._decode_subprocess_output(stderr), - ] - output = "\n".join(part for part in output_parts if part).strip() - return proc.returncode or 0, output + def _command_context( + self, + msg: InboundMessage, + *, + session: Session | None = None, + key: str | None = None, + ) -> CommandContext: + return CommandContext( + msg=msg, + session=session, + key=key or msg.session_key, + raw=msg.content.strip(), + loop=self, + ) async def _handle_skill_command(self, msg: InboundMessage, session: Session) -> OutboundMessage: """Handle ClawHub skill management commands for the active workspace.""" language = self._get_session_language(session) parts = msg.content.strip().split() - search_query: str | None = None - if len(parts) == 1: - return OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content=text(language, "skill_usage"), - ) - - subcommand = parts[1].lower() - workspace = str(self.workspace) + subcommand = self._skill_subcommand(parts) + if not subcommand: + return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=text(language, "skill_usage")) if subcommand == "search": - query_parts = msg.content.strip().split(None, 2) - if len(query_parts) < 3 or not query_parts[2].strip(): + query = self._skill_search_query(msg.content) + if not query: return OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content=text(language, "skill_search_missing_query"), ) - search_query = query_parts[2].strip() - code, output = await self._run_clawhub( - language, - "search", - search_query, - "--limit", - "5", - ) - elif subcommand == "install": - if len(parts) < 3: + return await self._skill_commands.search(msg, language, query) + + if subcommand == "install": + slug = self._skill_argument(parts) + if not slug: return OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content=text(language, "skill_install_missing_slug"), ) - code, output = await self._run_clawhub( - language, - "install", - parts[2], - "--workdir", - workspace, - timeout_seconds=self._CLAWHUB_INSTALL_TIMEOUT_SECONDS, - ) - elif subcommand == "uninstall": - if len(parts) < 3: + return await self._skill_commands.install(msg, language, slug) + + if subcommand == "uninstall": + slug = self._skill_argument(parts) + if not slug: return OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content=text(language, "skill_uninstall_missing_slug"), ) - code, output = await self._run_clawhub( - language, - "uninstall", - parts[2], - "--yes", - "--workdir", - workspace, - ) - elif subcommand == "list": - code, output = await self._run_clawhub(language, "list", "--workdir", workspace) - elif subcommand == "update": - code, output = await self._run_clawhub( - language, - "update", - "--all", - "--workdir", - workspace, - timeout_seconds=self._CLAWHUB_INSTALL_TIMEOUT_SECONDS, - ) - else: - return OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content=text(language, "skill_usage"), - ) + return await self._skill_commands.uninstall(msg, language, slug) - if code != 0: - content = self._format_clawhub_error(language, code, output) - return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=content) + if subcommand == "list": + return await self._skill_commands.list(msg, language) - if subcommand == "search" and not output: - return OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content=text(language, "skill_search_no_results", query=search_query or ""), - ) + if subcommand == "update": + return await self._skill_commands.update(msg, language) - notes: list[str] = [] - if output: - notes.append(output) - if subcommand in {"install", "uninstall", "update"}: - notes.append(text(language, "skill_applied_to_workspace", workspace=workspace)) - content = "\n\n".join(notes) if notes else text(language, "skill_command_completed", command=subcommand) - return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=content) + return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=text(language, "skill_usage")) async def _handle_mcp_command(self, msg: InboundMessage, session: Session) -> OutboundMessage: """Handle MCP inspection commands.""" @@ -551,37 +457,7 @@ class AgentLoop: content=self._mcp_usage(language), ) - await self._reload_mcp_servers_if_needed() - - if not self._mcp_servers: - return OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content=text(language, "mcp_no_servers"), - ) - - await self._connect_mcp() - - server_lines = "\n".join(f"- {name}" for name in self._mcp_servers) - sections = [text(language, "mcp_servers_list", items=server_lines)] - - grouped_tools = self._group_mcp_tool_names() - tool_lines = "\n".join( - f"- {server}: {', '.join(tools)}" - for server, tools in grouped_tools.items() - if tools - ) - sections.append( - text(language, "mcp_tools_list", items=tool_lines) - if tool_lines - else text(language, "mcp_no_tools") - ) - - return OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content="\n\n".join(sections), - ) + return await self._mcp_commands.list(msg, language) def _register_default_tools(self) -> None: """Register the default set of tools.""" @@ -661,28 +537,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"}, - ) - @staticmethod def _voice_reply_extension(response_format: str) -> str: """Map TTS response formats to delivery file extensions.""" @@ -972,14 +826,14 @@ class AgentLoop: logger.warning("Error consuming inbound message: {}, continuing...", e) continue - cmd = self._command_name(msg.content) - 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)) + ctx = self._command_context( + msg, + session=self.sessions.get_or_create(msg.session_key), + ) + if self._command_router.is_priority(ctx.raw): + result = await self._command_router.dispatch_priority(ctx) + if result is not None: + await self.bus.publish_outbound(result) else: task = asyncio.create_task(self._dispatch(msg)) self._active_tasks.setdefault(msg.session_key, []).append(task) @@ -1069,27 +923,14 @@ class AgentLoop: async def _handle_language_command(self, msg: InboundMessage, session: Session) -> OutboundMessage: """Handle session-scoped language switching commands.""" - current = self._get_session_language(session) parts = msg.content.strip().split() + current = self._get_session_language(session) if len(parts) == 1 or parts[1].lower() == "current": - return OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content=text(current, "current_language", language_name=language_label(current, current)), - ) + return self._language_commands.current(msg, session) subcommand = parts[1].lower() if subcommand == "list": - items = "\n".join( - f"- {language_label(code, current)}" - + (f" ({text(current, 'current_marker')})" if code == current else "") - for code in list_languages() - ) - return OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content=text(current, "available_languages", items=items), - ) + return self._language_commands.list(msg, session) if subcommand != "set" or len(parts) < 3: return OutboundMessage( @@ -1098,55 +939,18 @@ class AgentLoop: content=self._language_usage(current), ) - target = normalize_language_code(parts[2]) - if target is None: - languages = ", ".join(language_label(code, current) for code in list_languages()) - return OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content=text(current, "unknown_language", name=parts[2], languages=languages), - ) - - if target == current: - return OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content=text(current, "language_already_active", language_name=language_label(target, current)), - ) - - self._set_session_language(session, target) - self.sessions.save(session) - return OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content=text(target, "switched_language", language_name=language_label(target, target)), - ) + return self._language_commands.set(msg, session, parts[2]) async def _handle_persona_command(self, msg: InboundMessage, session: Session) -> OutboundMessage: """Handle session-scoped persona management commands.""" language = self._get_session_language(session) parts = msg.content.strip().split() if len(parts) == 1 or parts[1].lower() == "current": - current = self._get_session_persona(session) - return OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content=text(language, "current_persona", persona=current), - ) + return self._persona_commands.current(msg, session) subcommand = parts[1].lower() if subcommand == "list": - current = self._get_session_persona(session) - marker = text(language, "current_marker") - personas = [ - f"{name} ({marker})" if name == current else name - for name in self.context.list_personas() - ] - return OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content=text(language, "available_personas", items="\n".join(f"- {name}" for name in personas)), - ) + return self._persona_commands.list(msg, session) if subcommand != "set" or len(parts) < 3: return OutboundMessage( @@ -1155,53 +959,7 @@ class AgentLoop: content=self._persona_usage(language), ) - target = self.context.find_persona(parts[2]) - if target is None: - personas = ", ".join(self.context.list_personas()) - return OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content=text( - language, - "unknown_persona", - name=parts[2], - personas=personas, - path=self.workspace / "personas" / parts[2], - ), - ) - - current = self._get_session_persona(session) - if target == current: - return OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content=text(language, "persona_already_active", persona=target), - ) - - try: - if not await self.memory_consolidator.archive_unconsolidated(session): - return OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content=text(language, "memory_archival_failed_persona"), - ) - except Exception: - logger.exception("/persona archival failed for {}", session.key) - return OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content=text(language, "memory_archival_failed_persona"), - ) - - session.clear() - self._set_session_persona(session, target) - self.sessions.save(session) - self.sessions.invalidate(session.key) - return OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content=text(language, "switched_persona", persona=target), - ) + return await self._persona_commands.set(msg, session, parts[2]) async def close_mcp(self) -> None: """Drain pending background archives, then close MCP connections.""" @@ -1320,35 +1078,11 @@ class AgentLoop: language = self._get_session_language(session) # Slash commands - cmd = self._command_name(msg.content) - if cmd == "/new": - snapshot = session.messages[session.last_consolidated:] - session.clear() - self.sessions.save(session) - self.sessions.invalidate(session.key) - - if snapshot: - self._schedule_background(self.memory_consolidator.archive_messages(session, snapshot)) - - return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, - content=text(language, "new_session_started")) - if cmd == "/status": - return self._status_response(msg, session) - if cmd in {"/lang", "/language"}: - return await self._handle_language_command(msg, session) - if cmd == "/persona": - return await self._handle_persona_command(msg, session) - if cmd == "/skill": - return await self._handle_skill_command(msg, session) - if cmd == "/mcp": - return await self._handle_mcp_command(msg, session) - if cmd == "/help": - return OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content="\n".join(help_lines(language)), - metadata={"render_as": "text"}, - ) + slash_response = await self._command_router.dispatch( + self._command_context(msg, session=session, key=key) + ) + if slash_response is not None: + return slash_response await self._connect_mcp() await self._run_preflight_token_consolidation(session) diff --git a/nanobot/locales/en.json b/nanobot/locales/en.json index dd0b9a3..8467e3d 100644 --- a/nanobot/locales/en.json +++ b/nanobot/locales/en.json @@ -21,8 +21,21 @@ "skill_usage": "Usage:\n/skill search \n/skill install \n/skill uninstall \n/skill list\n/skill update", "skill_search_missing_query": "Missing query.\n\nUsage:\n/skill search ", "skill_search_no_results": "No skills found for \"{query}\". Try broader keywords, or use /skill install if you know the exact slug.", + "skill_search_results_header": "Found {total} skills for \"{query}\". Showing top {count}:", + "skill_search_result_meta": "slug: {slug} | owner: {owner} | installs: {installs} | stars: {stars} | version: {version}", + "skill_search_timeout": "ClawHub search timed out. Check network, proxy, or registry connectivity and retry.", + "skill_search_failed": "ClawHub search failed.", + "skill_search_failed_status": "ClawHub search failed with HTTP {status}.", + "skill_search_request_failed": "ClawHub search request failed. Check network, proxy, or registry connectivity and retry.", + "skill_search_invalid_response": "ClawHub search returned an unexpected response.", "skill_install_missing_slug": "Missing skill slug.\n\nUsage:\n/skill install ", "skill_uninstall_missing_slug": "Missing skill slug.\n\nUsage:\n/skill uninstall ", + "skill_invalid_slug": "Invalid skill slug: {slug}", + "skill_uninstall_not_found": "Skill {slug} is not installed at {path}.", + "skill_uninstall_failed": "Failed to remove local skill {slug} at {path}.", + "skill_uninstalled_local": "Removed local skill {slug} from {path}.", + "skill_lockfile_pruned": "Updated ClawHub lockfile: {path}", + "skill_lockfile_cleanup_failed": "Removed the local skill, but could not update the ClawHub lockfile at {path}.", "skill_npx_missing": "npx is not installed. Install Node.js first, then retry /skill.", "skill_command_timeout": "The ClawHub command timed out. Check npm connectivity or proxy settings and try again.", "skill_command_failed": "ClawHub command failed with exit code {code}.", diff --git a/nanobot/locales/zh.json b/nanobot/locales/zh.json index d16da2e..2d19c1b 100644 --- a/nanobot/locales/zh.json +++ b/nanobot/locales/zh.json @@ -21,8 +21,21 @@ "skill_usage": "用法:\n/skill search \n/skill install \n/skill uninstall \n/skill list\n/skill update", "skill_search_missing_query": "缺少搜索关键词。\n\n用法:\n/skill search ", "skill_search_no_results": "没有找到与“{query}”相关的 skill。请尝试更宽泛的关键词;如果你知道精确 slug,也可以直接用 /skill install 。", + "skill_search_results_header": "找到 {total} 个与“{query}”相关的 skill,显示前 {count} 个:", + "skill_search_result_meta": "slug:{slug} | 作者:{owner} | 安装:{installs} | 星标:{stars} | 版本:{version}", + "skill_search_timeout": "ClawHub 搜索超时。请检查网络、代理或 registry 连通性后重试。", + "skill_search_failed": "ClawHub 搜索失败。", + "skill_search_failed_status": "ClawHub 搜索失败,HTTP 状态码 {status}。", + "skill_search_request_failed": "ClawHub 搜索请求失败。请检查网络、代理或 registry 连通性后重试。", + "skill_search_invalid_response": "ClawHub 搜索返回了无法解析的响应。", "skill_install_missing_slug": "缺少 skill slug。\n\n用法:\n/skill install ", "skill_uninstall_missing_slug": "缺少 skill slug。\n\n用法:\n/skill uninstall ", + "skill_invalid_slug": "无效的 skill slug:{slug}", + "skill_uninstall_not_found": "在 {path} 没有找到已安装的 skill:{slug}。", + "skill_uninstall_failed": "删除本地 skill 失败:{slug}({path})。", + "skill_uninstalled_local": "已删除本地 skill:{slug}({path})。", + "skill_lockfile_pruned": "已更新 ClawHub lockfile:{path}", + "skill_lockfile_cleanup_failed": "本地 skill 已删除,但无法更新 ClawHub lockfile:{path}。", "skill_npx_missing": "未安装 npx。请先安装 Node.js,然后再重试 /skill。", "skill_command_timeout": "ClawHub 命令执行超时。请检查 npm 网络、代理或 registry 配置后重试。", "skill_command_failed": "ClawHub 命令执行失败,退出码 {code}。", diff --git a/nanobot/skills/clawhub/SKILL.md b/nanobot/skills/clawhub/SKILL.md index 0f5c726..aed56ca 100644 --- a/nanobot/skills/clawhub/SKILL.md +++ b/nanobot/skills/clawhub/SKILL.md @@ -20,14 +20,19 @@ Use this skill when the user asks any of: ## Search +Query the live registry API directly: + ```bash -npx --yes clawhub@latest search "web scraping" --limit 5 +curl 'https://lightmake.site/api/skills?page=1&pageSize=5&sortBy=score&order=desc&keyword=web%20scraping' \ + -H 'accept: */*' \ + -H 'origin: https://skillhub.tencent.com' \ + -H 'referer: https://skillhub.tencent.com/' ``` ## Install ```bash -npx --yes clawhub@latest install --workdir +npx --yes clawhub@latest --workdir --no-input install ``` Replace `` with the skill name from search results. Replace `` with the @@ -38,20 +43,34 @@ active workspace for the current nanobot process. This places the skill into ## Update ```bash -npx --yes clawhub@latest update --all --workdir +npx --yes clawhub@latest --workdir --no-input update --all ``` ## List installed ```bash -npx --yes clawhub@latest list --workdir +npx --yes clawhub@latest --workdir --no-input list ``` +## Uninstall from nanobot workspace + +Current ClawHub docs do not document a local uninstall subcommand. In nanobot, remove a +workspace-installed skill with: + +```text +/skill uninstall +``` + +This deletes `/skills/` and best-effort prunes +`/.clawhub/lock.json`. + ## Notes -- Requires Node.js (`npx` comes with it). +- Search uses the public registry API directly and does not require Node.js. +- Install/list/update require Node.js (`npx` comes with it). - No API key needed for search and install. - Login (`npx --yes clawhub@latest login`) is only required for publishing. - `--workdir ` is critical — without it, skills install to the current directory instead of the active nanobot workspace. +- Keep global options before the subcommand: `--workdir ... --no-input install ...`. - After install, remind the user to start a new session to load the skill. diff --git a/tests/test_consolidate_offset.py b/tests/test_consolidate_offset.py index 4f2e8f1..1e9efe9 100644 --- a/tests/test_consolidate_offset.py +++ b/tests/test_consolidate_offset.py @@ -1,10 +1,11 @@ """Test session management with cache-friendly message handling.""" import asyncio +from pathlib import Path from unittest.mock import AsyncMock, MagicMock import pytest -from pathlib import Path + from nanobot.session.manager import Session, SessionManager # Test constants diff --git a/tests/test_restart_command.py b/tests/test_restart_command.py index 3281afe..44a4f69 100644 --- a/tests/test_restart_command.py +++ b/tests/test_restart_command.py @@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from nanobot.bus.events import InboundMessage, OutboundMessage +from nanobot.bus.events import InboundMessage from nanobot.providers.base import LLMResponse diff --git a/tests/test_skill_commands.py b/tests/test_skill_commands.py index f24f09d..b852c77 100644 --- a/tests/test_skill_commands.py +++ b/tests/test_skill_commands.py @@ -5,6 +5,7 @@ from __future__ import annotations from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch +import httpx import pytest from nanobot.bus.events import InboundMessage @@ -38,68 +39,157 @@ class _FakeProcess: self.killed = True -@pytest.mark.asyncio -async def test_skill_search_runs_clawhub_search(tmp_path: Path) -> None: - loop = _make_loop(tmp_path) - proc = _FakeProcess(stdout="skill-a\nskill-b") - create_proc = AsyncMock(return_value=proc) +class _FakeAsyncClient: + def __init__(self, *, response: httpx.Response | None = None, error: Exception | None = None) -> None: + self.response = response + self.error = error + self.calls: list[dict[str, object]] = [] - with patch("nanobot.agent.loop.shutil.which", return_value="/usr/bin/npx"), \ - patch("nanobot.agent.loop.asyncio.create_subprocess_exec", create_proc): + async def __aenter__(self) -> _FakeAsyncClient: + return self + + async def __aexit__(self, exc_type, exc, tb) -> bool: + return False + + async def get(self, url: str, *, params: dict[str, str] | None = None, headers: dict[str, str] | None = None): + self.calls.append({"url": url, "params": params, "headers": headers}) + if self.error is not None: + raise self.error + assert self.response is not None + return self.response + + +@pytest.mark.asyncio +async def test_skill_search_uses_registry_api(tmp_path: Path) -> None: + loop = _make_loop(tmp_path) + request = httpx.Request("GET", "https://lightmake.site/api/skills") + response = httpx.Response( + 200, + request=request, + json={ + "code": 0, + "data": { + "skills": [ + { + "name": "News Aggregator Skill", + "slug": "news-aggregator-skill", + "ownerName": "cclank", + "installs": 667, + "stars": 19, + "version": "0.1.0", + "description": "Fetches and analyzes real-time news.", + "description_zh": "抓取并分析实时新闻。", + "homepage": "https://clawhub.ai/cclank/news-aggregator-skill", + } + ], + "total": 42, + }, + "message": "success", + }, + ) + client = _FakeAsyncClient(response=response) + create_proc = AsyncMock() + + with patch("nanobot.agent.commands.skill.httpx.AsyncClient", return_value=client), \ + patch("nanobot.agent.commands.skill.asyncio.create_subprocess_exec", create_proc): response = await loop._process_message( InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/skill search web scraping") ) assert response is not None - assert response.content == "skill-a\nskill-b" - assert create_proc.await_count == 1 - args = create_proc.await_args.args - assert args == ( - "/usr/bin/npx", - "--yes", - "clawhub@latest", - "search", - "web scraping", - "--limit", - "5", - ) - env = create_proc.await_args.kwargs["env"] - assert env["npm_config_cache"].endswith("nanobot-npm-cache") - assert env["npm_config_fetch_retries"] == "0" - assert env["npm_config_fetch_timeout"] == "5000" + assert 'Found 42 skills for "web scraping"' in response.content + assert "slug: news-aggregator-skill | owner: cclank | installs: 667 | stars: 19 | version: 0.1.0" in response.content + assert "https://clawhub.ai/cclank/news-aggregator-skill" in response.content + assert create_proc.await_count == 0 + assert client.calls == [ + { + "url": "https://lightmake.site/api/skills", + "params": { + "page": "1", + "pageSize": "5", + "sortBy": "score", + "order": "desc", + "keyword": "web scraping", + }, + "headers": { + "accept": "*/*", + "accept-language": "en-US,en;q=0.9", + "origin": "https://skillhub.tencent.com", + "referer": "https://skillhub.tencent.com/", + }, + } + ] @pytest.mark.asyncio -async def test_skill_search_surfaces_npm_network_errors(tmp_path: Path) -> None: +async def test_skill_retries_after_clearing_corrupted_npx_cache(tmp_path: Path) -> None: loop = _make_loop(tmp_path) - proc = _FakeProcess( + broken_proc = _FakeProcess( returncode=1, stderr=( - "npm error code EAI_AGAIN\n" - "npm error request to https://registry.npmjs.org/clawhub failed" + "node:internal/modules/esm/resolve:201\n" + "Error: Cannot find package " + "'/tmp/nanobot-npm-cache/_npx/a92a6dbcf543fba6/node_modules/log-symbols/index.js' " + "imported from " + "'/tmp/nanobot-npm-cache/_npx/a92a6dbcf543fba6/node_modules/ora/index.js'\n" + "code: 'ERR_MODULE_NOT_FOUND'" ), ) - create_proc = AsyncMock(return_value=proc) + recovered_proc = _FakeProcess(stdout="demo-skill") + create_proc = AsyncMock(side_effect=[broken_proc, recovered_proc]) - with patch("nanobot.agent.loop.shutil.which", return_value="/usr/bin/npx"), \ - patch("nanobot.agent.loop.asyncio.create_subprocess_exec", create_proc): + with patch("nanobot.agent.commands.skill.shutil.which", return_value="/usr/bin/npx"), \ + patch("nanobot.agent.commands.skill.asyncio.create_subprocess_exec", create_proc), \ + patch("nanobot.agent.commands.skill.shutil.rmtree") as remove_tree: + response = await loop._process_message( + InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/skill list") + ) + + assert response is not None + assert response.content == "demo-skill" + assert create_proc.await_count == 2 + env = create_proc.await_args_list[0].kwargs["env"] + remove_tree.assert_called_once_with(Path(env["npm_config_cache"]) / "_npx", ignore_errors=True) + + +@pytest.mark.asyncio +async def test_skill_search_surfaces_registry_request_errors(tmp_path: Path) -> None: + loop = _make_loop(tmp_path) + request = httpx.Request("GET", "https://lightmake.site/api/skills") + client = _FakeAsyncClient( + error=httpx.ConnectError( + "temporary failure in name resolution", + request=request, + ) + ) + create_proc = AsyncMock() + + with patch("nanobot.agent.commands.skill.httpx.AsyncClient", return_value=client), \ + patch("nanobot.agent.commands.skill.asyncio.create_subprocess_exec", create_proc): response = await loop._process_message( InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/skill search test") ) assert response is not None - assert "could not reach the npm registry" in response.content - assert "EAI_AGAIN" in response.content + assert "ClawHub search request failed" in response.content + assert "temporary failure in name resolution" in response.content + assert create_proc.await_count == 0 @pytest.mark.asyncio async def test_skill_search_empty_output_returns_no_results(tmp_path: Path) -> None: loop = _make_loop(tmp_path) - proc = _FakeProcess(stdout="") - create_proc = AsyncMock(return_value=proc) + request = httpx.Request("GET", "https://lightmake.site/api/skills") + response = httpx.Response( + 200, + request=request, + json={"code": 0, "data": {"skills": [], "total": 0}, "message": "success"}, + ) + client = _FakeAsyncClient(response=response) + create_proc = AsyncMock() - with patch("nanobot.agent.loop.shutil.which", return_value="/usr/bin/npx"), \ - patch("nanobot.agent.loop.asyncio.create_subprocess_exec", create_proc): + with patch("nanobot.agent.commands.skill.httpx.AsyncClient", return_value=client), \ + patch("nanobot.agent.commands.skill.asyncio.create_subprocess_exec", create_proc): response = await loop._process_message( InboundMessage( channel="cli", @@ -111,6 +201,7 @@ async def test_skill_search_empty_output_returns_no_results(tmp_path: Path) -> N assert response is not None assert 'No skills found for "selfimprovingagent"' in response.content + assert create_proc.await_count == 0 @pytest.mark.asyncio @@ -122,11 +213,6 @@ async def test_skill_search_empty_output_returns_no_results(tmp_path: Path) -> N ("install", "demo-skill"), "Installed demo-skill", ), - ( - "/skill uninstall demo-skill", - ("uninstall", "demo-skill", "--yes"), - "Uninstalled demo-skill", - ), ( "/skill list", ("list",), @@ -146,8 +232,8 @@ async def test_skill_commands_use_active_workspace( proc = _FakeProcess(stdout=expected_output) create_proc = AsyncMock(return_value=proc) - with patch("nanobot.agent.loop.shutil.which", return_value="/usr/bin/npx"), \ - patch("nanobot.agent.loop.asyncio.create_subprocess_exec", create_proc): + with patch("nanobot.agent.commands.skill.shutil.which", return_value="/usr/bin/npx"), \ + patch("nanobot.agent.commands.skill.asyncio.create_subprocess_exec", create_proc): response = await loop._process_message( InboundMessage(channel="cli", sender_id="user", chat_id="direct", content=command) ) @@ -156,11 +242,37 @@ async def test_skill_commands_use_active_workspace( assert expected_output in response.content args = create_proc.await_args.args assert args[:3] == ("/usr/bin/npx", "--yes", "clawhub@latest") - assert args[3:] == (*expected_args, "--workdir", str(tmp_path)) + assert args[3:] == ("--workdir", str(tmp_path), "--no-input", *expected_args) if command != "/skill list": assert f"Applied to workspace: {tmp_path}" in response.content +@pytest.mark.asyncio +async def test_skill_uninstall_removes_local_workspace_skill_and_prunes_lockfile(tmp_path: Path) -> None: + loop = _make_loop(tmp_path) + skill_dir = tmp_path / "skills" / "demo-skill" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("# demo", encoding="utf-8") + lock_dir = tmp_path / ".clawhub" + lock_dir.mkdir() + lock_path = lock_dir / "lock.json" + lock_path.write_text( + '{"skills":{"demo-skill":{"version":"1.0.0"},"other-skill":{"version":"2.0.0"}}}', + encoding="utf-8", + ) + + response = await loop._process_message( + InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/skill uninstall demo-skill") + ) + + assert response is not None + assert "Removed local skill demo-skill" in response.content + assert "Updated ClawHub lockfile" in response.content + assert not skill_dir.exists() + assert '"demo-skill"' not in lock_path.read_text(encoding="utf-8") + assert '"other-skill"' in lock_path.read_text(encoding="utf-8") + + @pytest.mark.asyncio async def test_skill_help_includes_skill_command(tmp_path: Path) -> None: loop = _make_loop(tmp_path) @@ -177,7 +289,7 @@ async def test_skill_help_includes_skill_command(tmp_path: Path) -> None: async def test_skill_missing_npx_returns_guidance(tmp_path: Path) -> None: loop = _make_loop(tmp_path) - with patch("nanobot.agent.loop.shutil.which", return_value=None): + with patch("nanobot.agent.commands.skill.shutil.which", return_value=None): response = await loop._process_message( InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/skill list") )