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 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)

View File

@@ -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 <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_restart": "/restart — Restart the bot",
"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_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_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_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}",

View File

@@ -12,16 +12,19 @@
"cmd_persona_current": "/persona current — 查看当前人格",
"cmd_persona_list": "/persona list — 查看可用人格",
"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_restart": "/restart — 重启机器人",
"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_no_results": "没有找到与“{query}”相关的 skill。请尝试更宽泛的关键词如果你知道精确 slug也可以直接用 /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_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}",

View File

@@ -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 <search|install|list|update>" in response.content
assert "/skill <search|install|uninstall|list|update>" 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 <query>" 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 <slug>" in missing_uninstall_slug.content