mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-15 23:34:00 +00:00
feat(command): add /skill slash command to list enabled skills
- Register /skill in BUILTIN_COMMAND_SPECS with wrench icon - Add cmd_skill handler that lists skill names and descriptions - Disabled skills are excluded from the output - Add 6 tests covering empty list, names/descriptions, disabled filtering, fallback description, markdown output, and router registration
This commit is contained in:
parent
710d00a179
commit
6b6be20f32
@ -98,6 +98,12 @@ BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = (
|
|||||||
"Revert memory to a previous Dream snapshot.",
|
"Revert memory to a previous Dream snapshot.",
|
||||||
"undo-2",
|
"undo-2",
|
||||||
),
|
),
|
||||||
|
BuiltinCommandSpec(
|
||||||
|
"/skill",
|
||||||
|
"List skills",
|
||||||
|
"List all enabled skills available to the agent.",
|
||||||
|
"wrench",
|
||||||
|
),
|
||||||
BuiltinCommandSpec(
|
BuiltinCommandSpec(
|
||||||
"/help",
|
"/help",
|
||||||
"Show help",
|
"Show help",
|
||||||
@ -642,6 +648,25 @@ async def cmd_pairing(ctx: CommandContext) -> OutboundMessage:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_skill(ctx: CommandContext) -> OutboundMessage:
|
||||||
|
"""List all enabled skills (name and description only)."""
|
||||||
|
loop = ctx.loop
|
||||||
|
skills = loop.context.skills.list_skills(filter_unavailable=False)
|
||||||
|
if not skills:
|
||||||
|
content = "No skills available."
|
||||||
|
else:
|
||||||
|
lines = [f"Available skills ({len(skills)}):", ""]
|
||||||
|
for entry in skills:
|
||||||
|
desc = loop.context.skills._get_skill_description(entry["name"])
|
||||||
|
lines.append(f"- **{entry['name']}** — {desc}")
|
||||||
|
content = "\n".join(lines)
|
||||||
|
return OutboundMessage(
|
||||||
|
channel=ctx.msg.channel,
|
||||||
|
chat_id=ctx.msg.chat_id,
|
||||||
|
content=content,
|
||||||
|
metadata=dict(ctx.msg.metadata or {}),
|
||||||
|
)
|
||||||
|
|
||||||
async def cmd_help(ctx: CommandContext) -> OutboundMessage:
|
async def cmd_help(ctx: CommandContext) -> OutboundMessage:
|
||||||
"""Return available slash commands."""
|
"""Return available slash commands."""
|
||||||
return OutboundMessage(
|
return OutboundMessage(
|
||||||
@ -681,6 +706,7 @@ def register_builtin_commands(router: CommandRouter) -> None:
|
|||||||
router.prefix("/dream-log ", cmd_dream_log)
|
router.prefix("/dream-log ", cmd_dream_log)
|
||||||
router.exact("/dream-restore", cmd_dream_restore)
|
router.exact("/dream-restore", cmd_dream_restore)
|
||||||
router.prefix("/dream-restore ", cmd_dream_restore)
|
router.prefix("/dream-restore ", cmd_dream_restore)
|
||||||
|
router.exact("/skill", cmd_skill)
|
||||||
router.exact("/help", cmd_help)
|
router.exact("/help", cmd_help)
|
||||||
router.exact("/pairing", cmd_pairing)
|
router.exact("/pairing", cmd_pairing)
|
||||||
router.prefix("/pairing ", cmd_pairing)
|
router.prefix("/pairing ", cmd_pairing)
|
||||||
|
|||||||
138
tests/command/test_skill_command.py
Normal file
138
tests/command/test_skill_command.py
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
"""Tests for the /skill built-in command."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.agent.skills import SkillsLoader
|
||||||
|
from nanobot.bus.events import InboundMessage
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.command.builtin import cmd_skill, register_builtin_commands
|
||||||
|
from nanobot.command.router import CommandContext, CommandRouter
|
||||||
|
from nanobot.config.schema import ModelPresetConfig
|
||||||
|
|
||||||
|
|
||||||
|
def _provider(default_model: str = "test-model") -> MagicMock:
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.get_default_model.return_value = default_model
|
||||||
|
provider.generation = MagicMock()
|
||||||
|
provider.generation.max_tokens = 4096
|
||||||
|
provider.generation.temperature = 0.1
|
||||||
|
provider.generation.reasoning_effort = None
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
def _make_loop(tmp_path: Path) -> AgentLoop:
|
||||||
|
return AgentLoop(
|
||||||
|
bus=MessageBus(),
|
||||||
|
provider=_provider(),
|
||||||
|
workspace=tmp_path,
|
||||||
|
model="test-model",
|
||||||
|
context_window_tokens=8000,
|
||||||
|
model_presets={
|
||||||
|
"default": ModelPresetConfig(
|
||||||
|
model="test-model",
|
||||||
|
max_tokens=4096,
|
||||||
|
context_window_tokens=8000,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx(loop: AgentLoop, raw: str = "/skill") -> CommandContext:
|
||||||
|
msg = InboundMessage(channel="cli", sender_id="user", chat_id="direct", content=raw)
|
||||||
|
return CommandContext(msg=msg, session=None, key=msg.session_key, raw=raw, args="", loop=loop)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_skill(base: Path, name: str, *, description: str = "", body: str = "# Skill\n") -> None:
|
||||||
|
skill_dir = base / name
|
||||||
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
frontmatter = f"---\nname: {name}\n"
|
||||||
|
if description:
|
||||||
|
frontmatter += f"description: {description}\n"
|
||||||
|
frontmatter += "---\n\n"
|
||||||
|
(skill_dir / "SKILL.md").write_text(frontmatter + body, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _loop_with_skills(tmp_path: Path) -> AgentLoop:
|
||||||
|
"""Create a loop with an empty builtin dir so only workspace skills appear."""
|
||||||
|
loop = _make_loop(tmp_path)
|
||||||
|
empty_builtin = tmp_path / "empty_builtin"
|
||||||
|
empty_builtin.mkdir()
|
||||||
|
loop.context.skills = SkillsLoader(tmp_path, builtin_skills_dir=empty_builtin)
|
||||||
|
return loop
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skill_command_no_skills(tmp_path: Path) -> None:
|
||||||
|
loop = _loop_with_skills(tmp_path)
|
||||||
|
out = await cmd_skill(_ctx(loop))
|
||||||
|
assert out.content == "No skills available."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skill_command_lists_names_and_descriptions(tmp_path: Path) -> None:
|
||||||
|
ws_skills = tmp_path / "skills"
|
||||||
|
ws_skills.mkdir()
|
||||||
|
_write_skill(ws_skills, "weather", description="Get current weather and forecasts")
|
||||||
|
_write_skill(ws_skills, "cron", description="Schedule recurring tasks")
|
||||||
|
|
||||||
|
loop = _loop_with_skills(tmp_path)
|
||||||
|
out = await cmd_skill(_ctx(loop))
|
||||||
|
|
||||||
|
assert "Available skills (2):" in out.content
|
||||||
|
assert "**weather** — Get current weather and forecasts" in out.content
|
||||||
|
assert "**cron** — Schedule recurring tasks" in out.content
|
||||||
|
# Must NOT contain file paths
|
||||||
|
assert ".md" not in out.content
|
||||||
|
assert "/skills/" not in out.content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skill_command_excludes_disabled(tmp_path: Path) -> None:
|
||||||
|
ws_skills = tmp_path / "skills"
|
||||||
|
ws_skills.mkdir()
|
||||||
|
_write_skill(ws_skills, "alpha", description="Alpha skill")
|
||||||
|
_write_skill(ws_skills, "beta", description="Beta skill")
|
||||||
|
|
||||||
|
loop = _make_loop(tmp_path)
|
||||||
|
loop.context.skills.disabled_skills = {"alpha"}
|
||||||
|
|
||||||
|
out = await cmd_skill(_ctx(loop))
|
||||||
|
|
||||||
|
assert "alpha" not in out.content
|
||||||
|
assert "**beta** — Beta skill" in out.content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skill_command_fallback_description(tmp_path: Path) -> None:
|
||||||
|
ws_skills = tmp_path / "skills"
|
||||||
|
ws_skills.mkdir()
|
||||||
|
_write_skill(ws_skills, "plain", description="", body="# Plain Skill\n")
|
||||||
|
|
||||||
|
loop = _loop_with_skills(tmp_path)
|
||||||
|
out = await cmd_skill(_ctx(loop))
|
||||||
|
|
||||||
|
assert "**plain** — plain" in out.content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skill_command_no_render_as_text(tmp_path: Path) -> None:
|
||||||
|
"""Output is markdown; CLI should render it (not forced as plain text)."""
|
||||||
|
loop = _make_loop(tmp_path)
|
||||||
|
out = await cmd_skill(_ctx(loop))
|
||||||
|
assert out.metadata.get("render_as") != "text"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skill_command_registered_on_router(tmp_path: Path) -> None:
|
||||||
|
router = CommandRouter()
|
||||||
|
register_builtin_commands(router)
|
||||||
|
loop = _loop_with_skills(tmp_path)
|
||||||
|
|
||||||
|
out = await router.dispatch(_ctx(loop, "/skill"))
|
||||||
|
|
||||||
|
assert out is not None
|
||||||
|
assert "No skills available." in out.content
|
||||||
Loading…
x
Reference in New Issue
Block a user