mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-04-02 09:22:36 +00:00
feat(command): add /skill slash command for user-activated skill injection (#2488)
* feat(command): add /skill slash command for user-activated skill injection * test(command): add tests for /skill slash command * refactor(command): switch skill activation from /skill prefix to $-reference interceptor
This commit is contained in:
parent
d96b0b7833
commit
ac3855e394
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from nanobot import __version__
|
from nanobot import __version__
|
||||||
@ -11,6 +12,9 @@ from nanobot.bus.events import OutboundMessage
|
|||||||
from nanobot.command.router import CommandContext, CommandRouter
|
from nanobot.command.router import CommandContext, CommandRouter
|
||||||
from nanobot.utils.helpers import build_status_content
|
from nanobot.utils.helpers import build_status_content
|
||||||
|
|
||||||
|
# Pattern to match $skill-name tokens (word chars + hyphens)
|
||||||
|
_SKILL_REF = re.compile(r"\$([A-Za-z][A-Za-z0-9_-]*)")
|
||||||
|
|
||||||
|
|
||||||
async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
|
async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
|
||||||
"""Cancel all active tasks and subagents for the session."""
|
"""Cancel all active tasks and subagents for the session."""
|
||||||
@ -56,8 +60,10 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage:
|
|||||||
channel=ctx.msg.channel,
|
channel=ctx.msg.channel,
|
||||||
chat_id=ctx.msg.chat_id,
|
chat_id=ctx.msg.chat_id,
|
||||||
content=build_status_content(
|
content=build_status_content(
|
||||||
version=__version__, model=loop.model,
|
version=__version__,
|
||||||
start_time=loop._start_time, last_usage=loop._last_usage,
|
model=loop.model,
|
||||||
|
start_time=loop._start_time,
|
||||||
|
last_usage=loop._last_usage,
|
||||||
context_window_tokens=loop.context_window_tokens,
|
context_window_tokens=loop.context_window_tokens,
|
||||||
session_msg_count=len(session.get_history(max_messages=0)),
|
session_msg_count=len(session.get_history(max_messages=0)),
|
||||||
context_tokens_estimate=ctx_est,
|
context_tokens_estimate=ctx_est,
|
||||||
@ -70,18 +76,82 @@ async def cmd_new(ctx: CommandContext) -> OutboundMessage:
|
|||||||
"""Start a fresh session."""
|
"""Start a fresh session."""
|
||||||
loop = ctx.loop
|
loop = ctx.loop
|
||||||
session = ctx.session or loop.sessions.get_or_create(ctx.key)
|
session = ctx.session or loop.sessions.get_or_create(ctx.key)
|
||||||
snapshot = session.messages[session.last_consolidated:]
|
snapshot = session.messages[session.last_consolidated :]
|
||||||
session.clear()
|
session.clear()
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
loop.sessions.invalidate(session.key)
|
loop.sessions.invalidate(session.key)
|
||||||
if snapshot:
|
if snapshot:
|
||||||
loop._schedule_background(loop.memory_consolidator.archive_messages(snapshot))
|
loop._schedule_background(loop.memory_consolidator.archive_messages(snapshot))
|
||||||
return OutboundMessage(
|
return OutboundMessage(
|
||||||
channel=ctx.msg.channel, chat_id=ctx.msg.chat_id,
|
channel=ctx.msg.channel,
|
||||||
|
chat_id=ctx.msg.chat_id,
|
||||||
content="New session started.",
|
content="New session started.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_skill_list(ctx: CommandContext) -> OutboundMessage:
|
||||||
|
"""List all available skills."""
|
||||||
|
loader = ctx.loop.context.skills
|
||||||
|
skills = loader.list_skills(filter_unavailable=False)
|
||||||
|
if not skills:
|
||||||
|
return OutboundMessage(
|
||||||
|
channel=ctx.msg.channel,
|
||||||
|
chat_id=ctx.msg.chat_id,
|
||||||
|
content="No skills found.",
|
||||||
|
)
|
||||||
|
lines = ["Available skills (use $<name> to activate):"]
|
||||||
|
for s in skills:
|
||||||
|
desc = loader._get_skill_description(s["name"])
|
||||||
|
available = loader._check_requirements(loader._get_skill_meta(s["name"]))
|
||||||
|
mark = "✓" if available else "✗"
|
||||||
|
lines.append(f" {mark} {s['name']} — {desc}")
|
||||||
|
return OutboundMessage(
|
||||||
|
channel=ctx.msg.channel,
|
||||||
|
chat_id=ctx.msg.chat_id,
|
||||||
|
content="\n".join(lines),
|
||||||
|
metadata={"render_as": "text"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def intercept_skill_refs(ctx: CommandContext) -> OutboundMessage | None:
|
||||||
|
"""Scan message for $skill-name references and inject matching skills."""
|
||||||
|
refs = _SKILL_REF.findall(ctx.msg.content)
|
||||||
|
if not refs:
|
||||||
|
return None
|
||||||
|
loader = ctx.loop.context.skills
|
||||||
|
skill_names = {s["name"] for s in loader.list_skills(filter_unavailable=True)}
|
||||||
|
matched = []
|
||||||
|
for name in dict.fromkeys(refs): # deduplicate, preserve order
|
||||||
|
if name in skill_names:
|
||||||
|
matched.append(name)
|
||||||
|
if not matched:
|
||||||
|
return None
|
||||||
|
# Strip matched $refs from the message
|
||||||
|
message = ctx.msg.content
|
||||||
|
for name in matched:
|
||||||
|
message = re.sub(rf"\${re.escape(name)}\b", "", message)
|
||||||
|
message = message.strip()
|
||||||
|
# Build injected content
|
||||||
|
skill_blocks = []
|
||||||
|
for name in matched:
|
||||||
|
content = loader.load_skill(name)
|
||||||
|
if content:
|
||||||
|
stripped = loader._strip_frontmatter(content)
|
||||||
|
skill_blocks.append(f'<skill-content name="{name}">\n{stripped}\n</skill-content>')
|
||||||
|
if not skill_blocks:
|
||||||
|
return None
|
||||||
|
names = ", ".join(f"'{n}'" for n in matched)
|
||||||
|
injected = (
|
||||||
|
f"<system-reminder>\n"
|
||||||
|
f"The user activated skill(s) {names} via $-reference. "
|
||||||
|
f"The following skill content was auto-appended by the system.\n"
|
||||||
|
+ "\n".join(skill_blocks)
|
||||||
|
+ "\n</system-reminder>"
|
||||||
|
)
|
||||||
|
ctx.msg.content = f"{injected}\n\n{message}" if message else injected
|
||||||
|
return None # fall through to LLM
|
||||||
|
|
||||||
|
|
||||||
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(
|
||||||
@ -100,6 +170,8 @@ def build_help_text() -> str:
|
|||||||
"/stop — Stop the current task",
|
"/stop — Stop the current task",
|
||||||
"/restart — Restart the bot",
|
"/restart — Restart the bot",
|
||||||
"/status — Show bot status",
|
"/status — Show bot status",
|
||||||
|
"/skills — List available skills",
|
||||||
|
"$<name> — Activate a skill inline (e.g. $weather what's the forecast)",
|
||||||
"/help — Show available commands",
|
"/help — Show available commands",
|
||||||
]
|
]
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
@ -113,3 +185,5 @@ def register_builtin_commands(router: CommandRouter) -> None:
|
|||||||
router.exact("/new", cmd_new)
|
router.exact("/new", cmd_new)
|
||||||
router.exact("/status", cmd_status)
|
router.exact("/status", cmd_status)
|
||||||
router.exact("/help", cmd_help)
|
router.exact("/help", cmd_help)
|
||||||
|
router.exact("/skills", cmd_skill_list)
|
||||||
|
router.intercept(intercept_skill_refs)
|
||||||
|
|||||||
236
tests/cli/test_skill_command.py
Normal file
236
tests/cli/test_skill_command.py
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
"""Tests for /skills listing and $skill inline activation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.bus.events import InboundMessage
|
||||||
|
from nanobot.command.builtin import cmd_skill_list, intercept_skill_refs
|
||||||
|
from nanobot.command.router import CommandContext
|
||||||
|
|
||||||
|
|
||||||
|
def _make_loop():
|
||||||
|
"""Create a minimal AgentLoop with mocked dependencies."""
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
|
||||||
|
bus = MessageBus()
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.get_default_model.return_value = "test-model"
|
||||||
|
workspace = MagicMock()
|
||||||
|
workspace.__truediv__ = MagicMock(return_value=MagicMock())
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("nanobot.agent.loop.ContextBuilder"),
|
||||||
|
patch("nanobot.agent.loop.SessionManager"),
|
||||||
|
patch("nanobot.agent.loop.SubagentManager"),
|
||||||
|
):
|
||||||
|
loop = AgentLoop(bus=bus, provider=provider, workspace=workspace)
|
||||||
|
return loop, bus
|
||||||
|
|
||||||
|
|
||||||
|
def _make_ctx(content: str, loop=None):
|
||||||
|
"""Build a CommandContext for testing."""
|
||||||
|
if loop is None:
|
||||||
|
loop, _ = _make_loop()
|
||||||
|
msg = InboundMessage(channel="cli", sender_id="user", chat_id="direct", content=content)
|
||||||
|
return CommandContext(msg=msg, session=None, key=msg.session_key, raw=content, loop=loop)
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_skills_loader(skills=None, skill_content=None):
|
||||||
|
"""Return a mock SkillsLoader with configurable data."""
|
||||||
|
loader = MagicMock()
|
||||||
|
loader.list_skills.return_value = skills or []
|
||||||
|
loader.load_skill.side_effect = lambda name: (skill_content or {}).get(name)
|
||||||
|
loader._get_skill_description.side_effect = lambda name: f"{name} description"
|
||||||
|
loader._get_skill_meta.return_value = {}
|
||||||
|
loader._check_requirements.return_value = True
|
||||||
|
loader._strip_frontmatter.side_effect = lambda c: c
|
||||||
|
return loader
|
||||||
|
|
||||||
|
|
||||||
|
WEATHER_SKILLS = [
|
||||||
|
{"name": "weather", "path": "/skills/weather/SKILL.md", "source": "builtin"},
|
||||||
|
]
|
||||||
|
MULTI_SKILLS = [
|
||||||
|
{"name": "weather", "path": "/skills/weather/SKILL.md", "source": "builtin"},
|
||||||
|
{"name": "github", "path": "/skills/github/SKILL.md", "source": "builtin"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestSkillList:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_lists_available_skills(self):
|
||||||
|
loop, _ = _make_loop()
|
||||||
|
loader = _mock_skills_loader(skills=MULTI_SKILLS)
|
||||||
|
loop.context = MagicMock()
|
||||||
|
loop.context.skills = loader
|
||||||
|
|
||||||
|
ctx = _make_ctx("/skills", loop=loop)
|
||||||
|
result = await cmd_skill_list(ctx)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert "weather" in result.content
|
||||||
|
assert "github" in result.content
|
||||||
|
assert "✓" in result.content
|
||||||
|
assert "$" in result.content # hints about $ usage
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_shows_unavailable_mark(self):
|
||||||
|
loop, _ = _make_loop()
|
||||||
|
loader = _mock_skills_loader(
|
||||||
|
skills=[{"name": "tmux", "path": "/skills/tmux/SKILL.md", "source": "builtin"}]
|
||||||
|
)
|
||||||
|
loader._check_requirements.return_value = False
|
||||||
|
loop.context = MagicMock()
|
||||||
|
loop.context.skills = loader
|
||||||
|
|
||||||
|
ctx = _make_ctx("/skills", loop=loop)
|
||||||
|
result = await cmd_skill_list(ctx)
|
||||||
|
|
||||||
|
assert "✗" in result.content
|
||||||
|
assert "tmux" in result.content
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_skills(self):
|
||||||
|
loop, _ = _make_loop()
|
||||||
|
loader = _mock_skills_loader(skills=[])
|
||||||
|
loop.context = MagicMock()
|
||||||
|
loop.context.skills = loader
|
||||||
|
|
||||||
|
ctx = _make_ctx("/skills", loop=loop)
|
||||||
|
result = await cmd_skill_list(ctx)
|
||||||
|
|
||||||
|
assert "No skills found" in result.content
|
||||||
|
|
||||||
|
|
||||||
|
class TestSkillInterceptor:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_injects_single_skill(self):
|
||||||
|
loop, _ = _make_loop()
|
||||||
|
loader = _mock_skills_loader(
|
||||||
|
skills=WEATHER_SKILLS,
|
||||||
|
skill_content={"weather": "Use the weather API."},
|
||||||
|
)
|
||||||
|
loop.context = MagicMock()
|
||||||
|
loop.context.skills = loader
|
||||||
|
|
||||||
|
ctx = _make_ctx("$weather what is the forecast", loop=loop)
|
||||||
|
result = await intercept_skill_refs(ctx)
|
||||||
|
|
||||||
|
assert result is None # falls through to LLM
|
||||||
|
assert '<skill-content name="weather">' in ctx.msg.content
|
||||||
|
assert "Use the weather API." in ctx.msg.content
|
||||||
|
assert "what is the forecast" in ctx.msg.content
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_injects_multiple_skills(self):
|
||||||
|
loop, _ = _make_loop()
|
||||||
|
loader = _mock_skills_loader(
|
||||||
|
skills=MULTI_SKILLS,
|
||||||
|
skill_content={
|
||||||
|
"weather": "Weather skill content.",
|
||||||
|
"github": "GitHub skill content.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
loop.context = MagicMock()
|
||||||
|
loop.context.skills = loader
|
||||||
|
|
||||||
|
ctx = _make_ctx("$weather $github do something", loop=loop)
|
||||||
|
result = await intercept_skill_refs(ctx)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert '<skill-content name="weather">' in ctx.msg.content
|
||||||
|
assert '<skill-content name="github">' in ctx.msg.content
|
||||||
|
assert "do something" in ctx.msg.content
|
||||||
|
# Both skills wrapped in a single system-reminder
|
||||||
|
assert ctx.msg.content.count("<system-reminder>") == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skill_ref_anywhere_in_message(self):
|
||||||
|
loop, _ = _make_loop()
|
||||||
|
loader = _mock_skills_loader(
|
||||||
|
skills=WEATHER_SKILLS,
|
||||||
|
skill_content={"weather": "Weather skill content."},
|
||||||
|
)
|
||||||
|
loop.context = MagicMock()
|
||||||
|
loop.context.skills = loader
|
||||||
|
|
||||||
|
ctx = _make_ctx("tell me $weather the forecast for NYC", loop=loop)
|
||||||
|
result = await intercept_skill_refs(ctx)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert '<skill-content name="weather">' in ctx.msg.content
|
||||||
|
assert (
|
||||||
|
"tell me the forecast for NYC" in ctx.msg.content
|
||||||
|
or "tell me the forecast for NYC" in ctx.msg.content
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_match_passes_through(self):
|
||||||
|
loop, _ = _make_loop()
|
||||||
|
loader = _mock_skills_loader(skills=WEATHER_SKILLS)
|
||||||
|
loop.context = MagicMock()
|
||||||
|
loop.context.skills = loader
|
||||||
|
|
||||||
|
ctx = _make_ctx("just a normal message", loop=loop)
|
||||||
|
result = await intercept_skill_refs(ctx)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert ctx.msg.content == "just a normal message"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unknown_ref_ignored(self):
|
||||||
|
loop, _ = _make_loop()
|
||||||
|
loader = _mock_skills_loader(skills=WEATHER_SKILLS)
|
||||||
|
loop.context = MagicMock()
|
||||||
|
loop.context.skills = loader
|
||||||
|
|
||||||
|
ctx = _make_ctx("$nonexistent do something", loop=loop)
|
||||||
|
result = await intercept_skill_refs(ctx)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert ctx.msg.content == "$nonexistent do something"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_deduplicates_refs(self):
|
||||||
|
loop, _ = _make_loop()
|
||||||
|
loader = _mock_skills_loader(
|
||||||
|
skills=WEATHER_SKILLS,
|
||||||
|
skill_content={"weather": "Weather skill content."},
|
||||||
|
)
|
||||||
|
loop.context = MagicMock()
|
||||||
|
loop.context.skills = loader
|
||||||
|
|
||||||
|
ctx = _make_ctx("$weather $weather forecast", loop=loop)
|
||||||
|
result = await intercept_skill_refs(ctx)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert ctx.msg.content.count('<skill-content name="weather">') == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dollar_amount_not_matched(self):
|
||||||
|
loop, _ = _make_loop()
|
||||||
|
loader = _mock_skills_loader(skills=WEATHER_SKILLS)
|
||||||
|
loop.context = MagicMock()
|
||||||
|
loop.context.skills = loader
|
||||||
|
|
||||||
|
ctx = _make_ctx("I have $100 in my account", loop=loop)
|
||||||
|
result = await intercept_skill_refs(ctx)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert ctx.msg.content == "I have $100 in my account"
|
||||||
|
|
||||||
|
|
||||||
|
class TestHelpIncludesSkill:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_help_shows_skill_commands(self):
|
||||||
|
loop, _ = _make_loop()
|
||||||
|
msg = InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/help")
|
||||||
|
response = await loop._process_message(msg)
|
||||||
|
|
||||||
|
assert response is not None
|
||||||
|
assert "/skills" in response.content
|
||||||
|
assert "$" in response.content
|
||||||
Loading…
x
Reference in New Issue
Block a user