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