fix(skill): improve clawhub command handling

This commit is contained in:
Hua
2026-03-17 15:55:41 +08:00
parent d31d6cdbe6
commit 59b9b54cbc
4 changed files with 160 additions and 18 deletions

View File

@@ -8,6 +8,7 @@ import os
import re import re
import shutil import shutil
import sys import sys
import tempfile
from contextlib import AsyncExitStack from contextlib import AsyncExitStack
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Awaitable, Callable from typing import TYPE_CHECKING, Awaitable, Callable
@@ -57,7 +58,19 @@ class AgentLoop:
""" """
_TOOL_RESULT_MAX_CHARS = 16_000 _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__( def __init__(
self, self,
@@ -183,15 +196,40 @@ class AgentLoop:
"""Decode subprocess output conservatively for CLI surfacing.""" """Decode subprocess output conservatively for CLI surfacing."""
return data.decode("utf-8", errors="replace").strip() 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).""" """Run the ClawHub CLI and return (exit_code, combined_output)."""
npx = shutil.which("npx") npx = shutil.which("npx")
if not npx: if not npx:
return 127, text(language, "skill_npx_missing") return 127, text(language, "skill_npx_missing")
env = os.environ.copy() env = self._clawhub_env()
env.setdefault("NO_COLOR", "1")
env.setdefault("FORCE_COLOR", "0")
proc = None proc = None
try: try:
@@ -205,7 +243,7 @@ class AgentLoop:
env=env, env=env,
) )
stdout, stderr = await asyncio.wait_for( 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: except FileNotFoundError:
return 127, text(language, "skill_npx_missing") return 127, text(language, "skill_npx_missing")
@@ -231,6 +269,7 @@ class AgentLoop:
"""Handle ClawHub skill management commands for the active workspace.""" """Handle ClawHub skill management commands for the active workspace."""
language = self._get_session_language(session) language = self._get_session_language(session)
parts = msg.content.strip().split() parts = msg.content.strip().split()
search_query: str | None = None
if len(parts) == 1: if len(parts) == 1:
return OutboundMessage( return OutboundMessage(
channel=msg.channel, channel=msg.channel,
@@ -249,7 +288,14 @@ class AgentLoop:
chat_id=msg.chat_id, chat_id=msg.chat_id,
content=text(language, "skill_search_missing_query"), 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": elif subcommand == "install":
if len(parts) < 3: if len(parts) < 3:
return OutboundMessage( return OutboundMessage(
@@ -258,13 +304,38 @@ class AgentLoop:
content=text(language, "skill_install_missing_slug"), content=text(language, "skill_install_missing_slug"),
) )
code, output = await self._run_clawhub( 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": elif subcommand == "list":
code, output = await self._run_clawhub(language, "list", "--workdir", workspace) code, output = await self._run_clawhub(language, "list", "--workdir", workspace)
elif subcommand == "update": elif subcommand == "update":
code, output = await self._run_clawhub( code, output = await self._run_clawhub(
language, "update", "--all", "--workdir", workspace, language,
"update",
"--all",
"--workdir",
workspace,
timeout_seconds=self._CLAWHUB_INSTALL_TIMEOUT_SECONDS,
) )
else: else:
return OutboundMessage( return OutboundMessage(
@@ -274,13 +345,20 @@ class AgentLoop:
) )
if code != 0: 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) 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] = [] notes: list[str] = []
if output: if output:
notes.append(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)) 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) 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=content)

View File

@@ -12,16 +12,19 @@
"cmd_persona_current": "/persona current — Show the active persona", "cmd_persona_current": "/persona current — Show the active persona",
"cmd_persona_list": "/persona list — List available personas", "cmd_persona_list": "/persona list — List available personas",
"cmd_persona_set": "/persona set <name> — Switch persona and start a new session", "cmd_persona_set": "/persona set <name> — Switch persona and start a new session",
"cmd_skill": "/skill <search|install|list|update> ... — Manage ClawHub skills", "cmd_skill": "/skill <search|install|uninstall|list|update> ... — Manage ClawHub skills",
"cmd_stop": "/stop — Stop the current task", "cmd_stop": "/stop — Stop the current task",
"cmd_restart": "/restart — Restart the bot", "cmd_restart": "/restart — Restart the bot",
"cmd_help": "/help — Show available commands", "cmd_help": "/help — Show available commands",
"skill_usage": "Usage:\n/skill search <query>\n/skill install <slug>\n/skill list\n/skill update", "skill_usage": "Usage:\n/skill search <query>\n/skill install <slug>\n/skill uninstall <slug>\n/skill list\n/skill update",
"skill_search_missing_query": "Missing query.\n\nUsage:\n/skill search <query>", "skill_search_missing_query": "Missing query.\n\nUsage:\n/skill search <query>",
"skill_search_no_results": "No skills found for \"{query}\". Try broader keywords, or use /skill install <slug> if you know the exact slug.",
"skill_install_missing_slug": "Missing skill slug.\n\nUsage:\n/skill install <slug>", "skill_install_missing_slug": "Missing skill slug.\n\nUsage:\n/skill install <slug>",
"skill_uninstall_missing_slug": "Missing skill slug.\n\nUsage:\n/skill uninstall <slug>",
"skill_npx_missing": "npx is not installed. Install Node.js first, then retry /skill.", "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_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_command_completed": "ClawHub command completed: {command}",
"skill_applied_to_workspace": "Applied to workspace: {workspace}", "skill_applied_to_workspace": "Applied to workspace: {workspace}",
"current_persona": "Current persona: {persona}", "current_persona": "Current persona: {persona}",

View File

@@ -12,16 +12,19 @@
"cmd_persona_current": "/persona current — 查看当前人格", "cmd_persona_current": "/persona current — 查看当前人格",
"cmd_persona_list": "/persona list — 查看可用人格", "cmd_persona_list": "/persona list — 查看可用人格",
"cmd_persona_set": "/persona set <name> — 切换人格并开始新会话", "cmd_persona_set": "/persona set <name> — 切换人格并开始新会话",
"cmd_skill": "/skill <search|install|list|update> ... — 管理 ClawHub skills", "cmd_skill": "/skill <search|install|uninstall|list|update> ... — 管理 ClawHub skills",
"cmd_stop": "/stop — 停止当前任务", "cmd_stop": "/stop — 停止当前任务",
"cmd_restart": "/restart — 重启机器人", "cmd_restart": "/restart — 重启机器人",
"cmd_help": "/help — 查看命令帮助", "cmd_help": "/help — 查看命令帮助",
"skill_usage": "用法:\n/skill search <query>\n/skill install <slug>\n/skill list\n/skill update", "skill_usage": "用法:\n/skill search <query>\n/skill install <slug>\n/skill uninstall <slug>\n/skill list\n/skill update",
"skill_search_missing_query": "缺少搜索关键词。\n\n用法\n/skill search <query>", "skill_search_missing_query": "缺少搜索关键词。\n\n用法\n/skill search <query>",
"skill_search_no_results": "没有找到与“{query}”相关的 skill。请尝试更宽泛的关键词如果你知道精确 slug也可以直接用 /skill install <slug>。",
"skill_install_missing_slug": "缺少 skill slug。\n\n用法\n/skill install <slug>", "skill_install_missing_slug": "缺少 skill slug。\n\n用法\n/skill install <slug>",
"skill_uninstall_missing_slug": "缺少 skill slug。\n\n用法\n/skill uninstall <slug>",
"skill_npx_missing": "未安装 npx。请先安装 Node.js然后再重试 /skill。", "skill_npx_missing": "未安装 npx。请先安装 Node.js然后再重试 /skill。",
"skill_command_timeout": "ClawHub 命令执行超时,请稍后重试。", "skill_command_timeout": "ClawHub 命令执行超时。请检查 npm 网络、代理或 registry 配置后重试。",
"skill_command_failed": "ClawHub 命令执行失败,退出码 {code}。", "skill_command_failed": "ClawHub 命令执行失败,退出码 {code}。",
"skill_command_network_failed": "ClawHub 无法连接到 npm registry。请检查网络、代理或 npm registry 配置后重试。",
"skill_command_completed": "ClawHub 命令执行完成:{command}", "skill_command_completed": "ClawHub 命令执行完成:{command}",
"skill_applied_to_workspace": "已应用到工作区:{workspace}", "skill_applied_to_workspace": "已应用到工作区:{workspace}",
"current_persona": "当前人格:{persona}", "current_persona": "当前人格:{persona}",

View File

@@ -63,6 +63,54 @@ async def test_skill_search_runs_clawhub_search(tmp_path: Path) -> None:
"--limit", "--limit",
"5", "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 @pytest.mark.asyncio
@@ -74,6 +122,11 @@ async def test_skill_search_runs_clawhub_search(tmp_path: Path) -> None:
("install", "demo-skill"), ("install", "demo-skill"),
"Installed demo-skill", "Installed demo-skill",
), ),
(
"/skill uninstall demo-skill",
("uninstall", "demo-skill", "--yes"),
"Uninstalled demo-skill",
),
( (
"/skill list", "/skill list",
("list",), ("list",),
@@ -117,7 +170,7 @@ async def test_skill_help_includes_skill_command(tmp_path: Path) -> None:
) )
assert response is not None assert response is not None
assert "/skill <search|install|list|update>" in response.content assert "/skill <search|install|uninstall|list|update>" in response.content
@pytest.mark.asyncio @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( missing_slug = await loop._process_message(
InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/skill install") 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 usage is not None
assert "/skill search <query>" in usage.content assert "/skill search <query>" in usage.content
assert missing_slug is not None assert missing_slug is not None
assert "Missing skill slug" in missing_slug.content assert "Missing skill slug" in missing_slug.content
assert missing_uninstall_slug is not None
assert "/skill uninstall <slug>" in missing_uninstall_slug.content