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:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user