Compare commits
2 Commits
d31d6cdbe6
...
0126061d53
| Author | SHA1 | Date | |
|---|---|---|---|
| 0126061d53 | |||
| 59b9b54cbc |
@@ -24,12 +24,16 @@ Recent history favors short Conventional Commit subjects such as `fix(memory): .
|
||||
|
||||
## Security & Configuration Tips
|
||||
Do not commit real API keys, tokens, chat logs, or workspace data. Keep local secrets in `~/.nanobot/config.json` and use sanitized examples in docs and tests. If you change authentication, network access, or other safety-sensitive behavior, update `README.md` or `SECURITY.md` in the same PR.
|
||||
- If a change affects user-visible behavior, commands, workflows, or contributor conventions, update both `README.md` and `AGENTS.md` in the same patch so runtime docs and repo rules stay aligned.
|
||||
|
||||
## Chat Commands & Skills
|
||||
- Slash commands are handled in `nanobot/agent/loop.py`; keep parsing logic there instead of scattering command behavior across channels.
|
||||
- When a slash command changes user-visible wording, update both `nanobot/locales/en.json` and `nanobot/locales/zh.json`.
|
||||
- If a slash command should appear in Telegram's native command menu, also update `nanobot/channels/telegram.py`.
|
||||
- `/skill` currently supports `search`, `install`, `uninstall`, `list`, and `update`. Keep subcommand dispatch in `nanobot/agent/loop.py`.
|
||||
- `/skill` shells out to `npx clawhub@latest`; it requires Node.js/`npx` at runtime.
|
||||
- `/skill uninstall` runs in a non-interactive context, so keep passing `--yes` when shelling out to ClawHub.
|
||||
- Treat empty `/skill search` output as a user-visible "no results" case rather than a silent success. Surface npm/registry failures directly to the user.
|
||||
- Never hardcode `~/.nanobot/workspace` for skill installation or lookup. Use the active runtime workspace from config or `--workspace`.
|
||||
- Workspace skills in `<workspace>/skills/` take precedence over built-in skills with the same directory name.
|
||||
|
||||
|
||||
@@ -1347,6 +1347,7 @@ These commands are available inside chats handled by `nanobot agent` or `nanobot
|
||||
| `/persona set <name>` | Switch persona and start a new session |
|
||||
| `/skill search <query>` | Search public skills on ClawHub |
|
||||
| `/skill install <slug>` | Install a ClawHub skill into the active workspace |
|
||||
| `/skill uninstall <slug>` | Remove a ClawHub-managed skill from the active workspace |
|
||||
| `/skill list` | List ClawHub-managed skills in the active workspace |
|
||||
| `/skill update` | Update all ClawHub-managed skills in the active workspace |
|
||||
| `/stop` | Stop the current task |
|
||||
@@ -1354,9 +1355,14 @@ These commands are available inside chats handled by `nanobot agent` or `nanobot
|
||||
| `/help` | Show command help |
|
||||
|
||||
`/skill` uses the active workspace for the current process, not a hard-coded
|
||||
`~/.nanobot/workspace` path. If you start nanobot with `--workspace`, skill install/list/update
|
||||
`~/.nanobot/workspace` path. If you start nanobot with `--workspace`, skill install/uninstall/list/update
|
||||
operate on that workspace's `skills/` directory.
|
||||
|
||||
`/skill search` can legitimately return no matches. In that case nanobot now replies with a
|
||||
clear "no skills found" message instead of leaving the channel on a transient searching state.
|
||||
If `npx clawhub@latest` cannot reach the npm registry, nanobot also surfaces the registry/network
|
||||
error directly so the failure is visible to the user.
|
||||
|
||||
<details>
|
||||
<summary><b>Heartbeat (Periodic Tasks)</b></summary>
|
||||
|
||||
|
||||
@@ -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