feat(skills): /reload-skills slash command + skills_reload agent tool

Adds a public reload path for the in-process skill caches so newly
installed (or removed) skills become visible mid-session without a
gateway restart. Mirrors the shape of /reload-mcp.

Three surfaces:
* /reload-skills slash command — CLI (cli.py) and gateway (gateway/run.py),
  with /reload_skills alias for Telegram autocomplete and an explicit
  Discord registration.
* skills_reload agent tool (tools/skills_tool.py) — lets agents/subagents
  pick up freshly-installed skills via tool call.
* agent.skill_commands.reload_skills() — shared helper that clears
  _skill_commands, _SKILLS_PROMPT_CACHE (in-process LRU), and the
  on-disk .skills_prompt_snapshot.json, then returns an added/removed
  diff plus the new total count.

Tested:
* tests/agent/test_skill_commands_reload.py (9 cases)
* tests/cli/test_cli_reload_skills.py       (3 cases)
* tests/gateway/test_reload_skills_command.py (4 cases)

Use case: NemoClaw / OpenShell-style sandboxed orchestrators that drop
skills into ~/.hermes/skills mid-session, plus agentic flows where the
agent itself installs a skill via the shell tool and needs it bound
without a gateway restart. The Python helper
clear_skills_system_prompt_cache(clear_snapshot=True) already exists
internally — this PR just exposes it via slash command and tool.
This commit is contained in:
Shannon Sands
2026-04-29 13:58:45 +10:00
committed by Teknium
parent 113239f6e3
commit 7966560fb5
10 changed files with 682 additions and 4 deletions

View File

@@ -0,0 +1,77 @@
"""Tests for the ``/reload-skills`` CLI slash command (``HermesCLI._reload_skills``)."""
from unittest.mock import MagicMock, patch
def _make_cli():
"""Build a minimal HermesCLI shell exposing ``_reload_skills``."""
import cli as cli_mod
obj = object.__new__(cli_mod.HermesCLI)
obj._command_running = False
obj.conversation_history = []
obj.agent = None
return obj
class TestReloadSkillsCLI:
def test_reports_added_and_removed(self, capsys):
cli = _make_cli()
with patch(
"agent.skill_commands.reload_skills",
return_value={
"added": ["alpha", "beta"],
"removed": ["gamma"],
"unchanged": ["delta"],
"total": 3,
"commands": 3,
},
):
cli._reload_skills()
out = capsys.readouterr().out
assert "Added: alpha, beta" in out
assert "Removed: gamma" in out
assert "3 skill(s) available" in out
# An informational message should be appended to the conversation
# so the model picks up the diff on the next turn.
assert len(cli.conversation_history) == 1
msg = cli.conversation_history[0]
assert msg["role"] == "user"
assert "Skills have been reloaded" in msg["content"]
assert "alpha" in msg["content"]
assert "gamma" in msg["content"]
def test_reports_no_changes(self, capsys):
cli = _make_cli()
with patch(
"agent.skill_commands.reload_skills",
return_value={
"added": [],
"removed": [],
"unchanged": ["alpha"],
"total": 1,
"commands": 1,
},
):
cli._reload_skills()
out = capsys.readouterr().out
assert "No changes detected" in out
assert "1 skill(s) available" in out
# Nothing changed — don't pollute history.
assert cli.conversation_history == []
def test_handles_reload_failure_gracefully(self, capsys):
cli = _make_cli()
with patch(
"agent.skill_commands.reload_skills",
side_effect=RuntimeError("boom"),
):
cli._reload_skills()
out = capsys.readouterr().out
assert "Skills reload failed" in out
assert "boom" in out
# Failure must not append a misleading "skills reloaded" note.
assert cli.conversation_history == []