From 59b9b54cbc277d4bdaef7faa2697d97d61a48759 Mon Sep 17 00:00:00 2001 From: Hua Date: Tue, 17 Mar 2026 15:55:41 +0800 Subject: [PATCH] fix(skill): improve clawhub command handling --- nanobot/agent/loop.py | 100 +++++++++++++++++++++++++++++++---- nanobot/locales/en.json | 9 ++-- nanobot/locales/zh.json | 9 ++-- tests/test_skill_commands.py | 60 ++++++++++++++++++++- 4 files changed, 160 insertions(+), 18 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 478e011..c4a22ba 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -8,6 +8,7 @@ import os import re import shutil import sys +import tempfile from contextlib import AsyncExitStack from pathlib import Path from typing import TYPE_CHECKING, Awaitable, Callable @@ -57,7 +58,19 @@ class AgentLoop: """ _TOOL_RESULT_MAX_CHARS = 16_000 - _CLAWHUB_TIMEOUT_SECONDS = 300 + _CLAWHUB_TIMEOUT_SECONDS = 60 + _CLAWHUB_INSTALL_TIMEOUT_SECONDS = 180 + _CLAWHUB_NETWORK_ERROR_MARKERS = ( + "eai_again", + "enotfound", + "etimedout", + "econnrefused", + "econnreset", + "fetch failed", + "network request failed", + "registry.npmjs.org", + ) + _CLAWHUB_NPM_CACHE_DIR = Path(tempfile.gettempdir()) / "nanobot-npm-cache" def __init__( self, @@ -183,15 +196,40 @@ class AgentLoop: """Decode subprocess output conservatively for CLI surfacing.""" return data.decode("utf-8", errors="replace").strip() - async def _run_clawhub(self, language: str, *args: str) -> tuple[int, str]: + @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) + + 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) + + 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 = os.environ.copy() - env.setdefault("NO_COLOR", "1") - env.setdefault("FORCE_COLOR", "0") + env = self._clawhub_env() proc = None try: @@ -205,7 +243,7 @@ class AgentLoop: env=env, ) stdout, stderr = await asyncio.wait_for( - proc.communicate(), timeout=self._CLAWHUB_TIMEOUT_SECONDS, + proc.communicate(), timeout=timeout_seconds or self._CLAWHUB_TIMEOUT_SECONDS, ) except FileNotFoundError: return 127, text(language, "skill_npx_missing") @@ -231,6 +269,7 @@ class AgentLoop: """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, @@ -249,7 +288,14 @@ class AgentLoop: chat_id=msg.chat_id, content=text(language, "skill_search_missing_query"), ) - code, output = await self._run_clawhub(language, "search", query_parts[2].strip(), "--limit", "5") + 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 OutboundMessage( @@ -258,13 +304,38 @@ class AgentLoop: content=text(language, "skill_install_missing_slug"), ) code, output = await self._run_clawhub( - language, "install", parts[2], "--workdir", workspace, + language, + "install", + parts[2], + "--workdir", + workspace, + timeout_seconds=self._CLAWHUB_INSTALL_TIMEOUT_SECONDS, + ) + elif subcommand == "uninstall": + if len(parts) < 3: + 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, + language, + "update", + "--all", + "--workdir", + workspace, + timeout_seconds=self._CLAWHUB_INSTALL_TIMEOUT_SECONDS, ) else: return OutboundMessage( @@ -274,13 +345,20 @@ class AgentLoop: ) if code != 0: - content = output or text(language, "skill_command_failed", code=code) + content = self._format_clawhub_error(language, code, output) return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=content) + 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 ""), + ) + notes: list[str] = [] if output: notes.append(output) - if subcommand in {"install", "update"}: + 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) diff --git a/nanobot/locales/en.json b/nanobot/locales/en.json index 5be1eef..eb6c968 100644 --- a/nanobot/locales/en.json +++ b/nanobot/locales/en.json @@ -12,16 +12,19 @@ "cmd_persona_current": "/persona current — Show the active persona", "cmd_persona_list": "/persona list — List available personas", "cmd_persona_set": "/persona set — Switch persona and start a new session", - "cmd_skill": "/skill ... — Manage ClawHub skills", + "cmd_skill": "/skill ... — Manage ClawHub skills", "cmd_stop": "/stop — Stop the current task", "cmd_restart": "/restart — Restart the bot", "cmd_help": "/help — Show available commands", - "skill_usage": "Usage:\n/skill search \n/skill install \n/skill list\n/skill update", + "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_install_missing_slug": "Missing skill slug.\n\nUsage:\n/skill install ", + "skill_uninstall_missing_slug": "Missing skill slug.\n\nUsage:\n/skill uninstall ", "skill_npx_missing": "npx is not installed. Install Node.js first, then retry /skill.", - "skill_command_timeout": "The ClawHub command timed out. Please try again.", + "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}.", + "skill_command_network_failed": "ClawHub could not reach the npm registry. Check your network, proxy, or npm registry configuration and retry.", "skill_command_completed": "ClawHub command completed: {command}", "skill_applied_to_workspace": "Applied to workspace: {workspace}", "current_persona": "Current persona: {persona}", diff --git a/nanobot/locales/zh.json b/nanobot/locales/zh.json index 47c63b9..0ce6517 100644 --- a/nanobot/locales/zh.json +++ b/nanobot/locales/zh.json @@ -12,16 +12,19 @@ "cmd_persona_current": "/persona current — 查看当前人格", "cmd_persona_list": "/persona list — 查看可用人格", "cmd_persona_set": "/persona set — 切换人格并开始新会话", - "cmd_skill": "/skill ... — 管理 ClawHub skills", + "cmd_skill": "/skill ... — 管理 ClawHub skills", "cmd_stop": "/stop — 停止当前任务", "cmd_restart": "/restart — 重启机器人", "cmd_help": "/help — 查看命令帮助", - "skill_usage": "用法:\n/skill search \n/skill install \n/skill list\n/skill update", + "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_install_missing_slug": "缺少 skill slug。\n\n用法:\n/skill install ", + "skill_uninstall_missing_slug": "缺少 skill slug。\n\n用法:\n/skill uninstall ", "skill_npx_missing": "未安装 npx。请先安装 Node.js,然后再重试 /skill。", - "skill_command_timeout": "ClawHub 命令执行超时,请稍后重试。", + "skill_command_timeout": "ClawHub 命令执行超时。请检查 npm 网络、代理或 registry 配置后重试。", "skill_command_failed": "ClawHub 命令执行失败,退出码 {code}。", + "skill_command_network_failed": "ClawHub 无法连接到 npm registry。请检查网络、代理或 npm registry 配置后重试。", "skill_command_completed": "ClawHub 命令执行完成:{command}", "skill_applied_to_workspace": "已应用到工作区:{workspace}", "current_persona": "当前人格:{persona}", diff --git a/tests/test_skill_commands.py b/tests/test_skill_commands.py index ed44317..f24f09d 100644 --- a/tests/test_skill_commands.py +++ b/tests/test_skill_commands.py @@ -63,6 +63,54 @@ async def test_skill_search_runs_clawhub_search(tmp_path: Path) -> None: "--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" + + +@pytest.mark.asyncio +async def test_skill_search_surfaces_npm_network_errors(tmp_path: Path) -> None: + loop = _make_loop(tmp_path) + proc = _FakeProcess( + returncode=1, + stderr=( + "npm error code EAI_AGAIN\n" + "npm error request to https://registry.npmjs.org/clawhub failed" + ), + ) + 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): + 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 + + +@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) + + with patch("nanobot.agent.loop.shutil.which", return_value="/usr/bin/npx"), \ + patch("nanobot.agent.loop.asyncio.create_subprocess_exec", create_proc): + response = await loop._process_message( + InboundMessage( + channel="cli", + sender_id="user", + chat_id="direct", + content="/skill search selfimprovingagent", + ) + ) + + assert response is not None + assert 'No skills found for "selfimprovingagent"' in response.content @pytest.mark.asyncio @@ -74,6 +122,11 @@ async def test_skill_search_runs_clawhub_search(tmp_path: Path) -> None: ("install", "demo-skill"), "Installed demo-skill", ), + ( + "/skill uninstall demo-skill", + ("uninstall", "demo-skill", "--yes"), + "Uninstalled demo-skill", + ), ( "/skill list", ("list",), @@ -117,7 +170,7 @@ async def test_skill_help_includes_skill_command(tmp_path: Path) -> None: ) assert response is not None - assert "/skill " in response.content + assert "/skill " in response.content @pytest.mark.asyncio @@ -143,8 +196,13 @@ async def test_skill_usage_errors_are_user_facing(tmp_path: Path) -> None: missing_slug = await loop._process_message( InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/skill install") ) + missing_uninstall_slug = await loop._process_message( + InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/skill uninstall") + ) assert usage is not None assert "/skill search " in usage.content assert missing_slug is not None assert "Missing skill slug" in missing_slug.content + assert missing_uninstall_slug is not None + assert "/skill uninstall " in missing_uninstall_slug.content