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

@@ -284,6 +284,70 @@ def get_skill_commands() -> Dict[str, Dict[str, Any]]:
return _skill_commands
def reload_skills() -> Dict[str, Any]:
"""Re-scan the skills directory and invalidate every in-process skill cache.
Mirrors the ``/reload-mcp`` shape: clears state, rebuilds it, returns a
diff summary that the caller (CLI, gateway, or agent tool) can render
for the user / model.
What this clears:
* ``agent.skill_commands._skill_commands`` (slash-command map)
* ``agent.prompt_builder._SKILLS_PROMPT_CACHE`` (in-process LRU)
* ``.skills_prompt_snapshot.json`` on disk (cross-process snapshot)
What gets re-read on the next prompt build:
* ``~/.hermes/skills/`` and any ``skills.external_dirs``
* Plugin-provided skills
* ``skills.disabled`` / ``skills.platform_disabled`` from config.yaml
Returns:
Dict with keys::
{
"added": [skill names newly visible],
"removed": [skill names no longer visible],
"unchanged": [skill names present before and after],
"total": total skill count after rescan,
"commands": total /slash-skill count after rescan,
}
"""
# Snapshot pre-reload state from the cache (what the agent had been
# advertising). Comparing this to the post-rescan disk state shows
# the user/agent which skills actually appeared / disappeared.
before = set(_skill_commands.keys()) # /slash-form keys, e.g. "/demo"
# Clear the slash-command cache. ``scan_skill_commands`` already
# resets ``_skill_commands = {}`` internally, but we call the public
# rescan path so the result is observable to ``get_skill_commands``.
new_commands = scan_skill_commands()
# Clear the system-prompt cache (in-process LRU + on-disk snapshot)
# so the next prompt build re-walks the skills dir.
try:
from agent.prompt_builder import clear_skills_system_prompt_cache
clear_skills_system_prompt_cache(clear_snapshot=True)
except Exception as e: # pragma: no cover — best-effort
logger.debug("Could not clear skills prompt cache: %s", e)
after = set(new_commands.keys())
# Strip leading slash for display: callers compare bare skill names.
def _strip(s: set) -> set:
return {k.lstrip("/") for k in s}
added = sorted(_strip(after - before))
removed = sorted(_strip(before - after))
unchanged = sorted(_strip(after & before))
return {
"added": added,
"removed": removed,
"unchanged": unchanged,
"total": len(after),
"commands": len(new_commands),
}
def resolve_skill_command_key(command: str) -> Optional[str]:
"""Resolve a user-typed /command to its canonical skill_cmds key.