fix(skill): improve clawhub command handling
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user