mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-21 03:39:54 +00:00
b5c1fe78aa
Skill bundles are tiny YAML files in ~/.hermes/skill-bundles/ that
group several skills under one slash command. Invoking /<bundle-name>
from any surface (CLI, TUI, dashboard, any gateway platform) loads
every referenced skill into a single combined user message.
Use cases:
- /backend-dev → loads github-code-review + test-driven-development
+ github-pr-workflow as one bundle.
- /research → loads several research skills together.
- Team task profiles shared via dotfiles.
Behavior:
- Bundles take precedence over individual skills when slugs collide.
- Missing skills are skipped with a note, not fatal.
- No system-prompt mutation — bundles generate a fresh user message
at invocation time, the same way /<skill> does. Prompt cache stays
intact.
- Works in CLI dispatch, gateway dispatch, autocomplete (CLI + TUI),
/help display.
Schema (~/.hermes/skill-bundles/<slug>.yaml):
name: backend-dev
description: Backend feature work.
skills:
- github-code-review
- test-driven-development
instruction: |
Optional extra guidance prepended to the loaded skills.
New module: agent/skill_bundles.py — load, scan, resolve, build
invocation message, save, delete. yaml.safe_load only; broken
bundles log a warning and are skipped, never raise.
New CLI subcommand: hermes bundles {list,show,create,delete,reload}.
Implementation in hermes_cli/bundles.py; wired in hermes_cli/main.py.
'bundles' added to _BUILTIN_SUBCOMMANDS so plugin discovery skips it.
New in-session slash command: /bundles lists installed bundles in
both CLI and gateway. /<bundle-name> dispatch added to CLI (cli.py)
and gateway (gateway/run.py) before the existing /<skill-name> path.
Autocomplete: SlashCommandCompleter gained an optional
skill_bundles_provider parameter that defaults to None — the prompt
shows '▣ <description> (N skills)' for bundles vs '⚡' for skills.
Tests:
- tests/agent/test_skill_bundles.py — 33 tests covering slugify,
scan/cache freshness, resolve (including underscore→hyphen
Telegram alias), build_bundle_invocation_message (loading, missing
skills, user/bundle instruction injection, dedup), save/delete,
reload diff, list sort.
- tests/hermes_cli/test_bundles.py — 8 tests for the CLI
subcommand (create/list/show/delete/reload, --force, missing
bundle errors).
- tests/gateway/test_bundles_command.py — 4 tests for the gateway
handler and bundle resolution priority.
Live E2E: verified subprocess invocations of hermes bundles
{list,create,show,reload,delete} round-trip correctly against an
isolated HERMES_HOME.
Docs:
- website/docs/user-guide/features/skills.md — new 'Skill Bundles'
section with quick example, YAML schema, management commands,
behavior notes.
- website/docs/reference/cli-commands.md — 'hermes bundles' added to
the top-level command table and given its own subcommand section.
116 lines
3.8 KiB
Python
116 lines
3.8 KiB
Python
"""Tests for the ``/bundles`` gateway slash command handler.
|
|
|
|
Verifies that:
|
|
- ``_handle_bundles_command`` returns useful text when no bundles are
|
|
installed and when several are.
|
|
- Bundle dispatch in ``_handle_message`` rewrites ``event.text`` to the
|
|
combined skill content when the user types ``/<bundle-slug>``.
|
|
|
|
The actual ``/<bundle-slug>`` → combined-message build is tested in
|
|
``tests/agent/test_skill_bundles.py``; this file only checks the gateway
|
|
glue (handler wiring, dispatch ordering, event.text rewrite).
|
|
"""
|
|
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
|
from gateway.platforms.base import MessageEvent
|
|
from gateway.session import SessionSource
|
|
|
|
|
|
def _make_source() -> SessionSource:
|
|
return SessionSource(
|
|
platform=Platform.TELEGRAM,
|
|
user_id="u1",
|
|
chat_id="c1",
|
|
user_name="tester",
|
|
chat_type="dm",
|
|
)
|
|
|
|
|
|
def _make_event(text: str) -> MessageEvent:
|
|
return MessageEvent(text=text, source=_make_source(), message_id="m1")
|
|
|
|
|
|
def _make_runner():
|
|
from gateway.run import GatewayRunner
|
|
|
|
runner = object.__new__(GatewayRunner)
|
|
runner.config = GatewayConfig(
|
|
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")}
|
|
)
|
|
adapter = MagicMock()
|
|
adapter.send = AsyncMock()
|
|
runner.adapters = {Platform.TELEGRAM: adapter}
|
|
runner.hooks = SimpleNamespace(
|
|
emit=AsyncMock(),
|
|
emit_collect=AsyncMock(return_value=[]),
|
|
loaded_hooks=False,
|
|
)
|
|
return runner
|
|
|
|
|
|
@pytest.fixture
|
|
def bundles_env(tmp_path, monkeypatch):
|
|
bundles_dir = tmp_path / "skill-bundles"
|
|
skills_dir = tmp_path / "skills"
|
|
skills_dir.mkdir()
|
|
monkeypatch.setenv("HERMES_BUNDLES_DIR", str(bundles_dir))
|
|
import tools.skills_tool as skills_tool_module
|
|
monkeypatch.setattr(skills_tool_module, "SKILLS_DIR", skills_dir)
|
|
import agent.skill_bundles as mod
|
|
mod._bundles_cache = {}
|
|
mod._bundles_cache_mtime = None
|
|
return bundles_dir, skills_dir
|
|
|
|
|
|
def _make_skill(skills_dir, name, body="content"):
|
|
sd = skills_dir / name
|
|
sd.mkdir(parents=True, exist_ok=True)
|
|
(sd / "SKILL.md").write_text(
|
|
f"---\nname: {name}\ndescription: desc {name}\n---\n\n# {name}\n\n{body}\n"
|
|
)
|
|
|
|
|
|
def _make_bundle(bundles_dir, slug, skills):
|
|
bundles_dir.mkdir(parents=True, exist_ok=True)
|
|
(bundles_dir / f"{slug}.yaml").write_text(
|
|
f"name: {slug}\nskills:\n" + "\n".join(f" - {s}" for s in skills) + "\n"
|
|
)
|
|
|
|
|
|
class TestHandleBundlesCommand:
|
|
def test_empty(self, bundles_env):
|
|
runner = _make_runner()
|
|
result = asyncio.run(runner._handle_bundles_command(_make_event("/bundles")))
|
|
assert "No skill bundles" in result
|
|
|
|
def test_with_bundles(self, bundles_env):
|
|
bundles_dir, _ = bundles_env
|
|
_make_bundle(bundles_dir, "research", ["alpha", "beta"])
|
|
runner = _make_runner()
|
|
result = asyncio.run(runner._handle_bundles_command(_make_event("/bundles")))
|
|
assert "research" in result
|
|
assert "/research" in result
|
|
assert "2 skills" in result
|
|
|
|
|
|
class TestBundleResolutionPriority:
|
|
"""Verify resolve_bundle_command_key picks bundles over skills."""
|
|
|
|
def test_bundle_resolves(self, bundles_env):
|
|
bundles_dir, _ = bundles_env
|
|
_make_bundle(bundles_dir, "research", ["alpha"])
|
|
from agent.skill_bundles import resolve_bundle_command_key
|
|
assert resolve_bundle_command_key("research") == "/research"
|
|
|
|
def test_underscore_alias(self, bundles_env):
|
|
bundles_dir, _ = bundles_env
|
|
_make_bundle(bundles_dir, "my-bundle", ["alpha"])
|
|
from agent.skill_bundles import resolve_bundle_command_key
|
|
assert resolve_bundle_command_key("my_bundle") == "/my-bundle"
|