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

@@ -2270,6 +2270,10 @@ class DiscordAdapter(BasePlatformAdapter):
async def slash_reload_mcp(interaction: discord.Interaction):
await self._run_simple_slash(interaction, "/reload-mcp")
@tree.command(name="reload-skills", description="Re-scan ~/.hermes/skills/ for new or removed skills")
async def slash_reload_skills(interaction: discord.Interaction):
await self._run_simple_slash(interaction, "/reload-skills")
@tree.command(name="voice", description="Toggle voice reply mode")
@discord.app_commands.describe(mode="Voice mode: on, off, tts, channel, leave, or status")
@discord.app_commands.choices(mode=[

View File

@@ -4219,6 +4219,9 @@ class GatewayRunner:
if canonical == "reload-mcp":
return await self._handle_reload_mcp_command(event)
if canonical == "reload-skills":
return await self._handle_reload_skills_command(event)
if canonical == "approve":
return await self._handle_approve_command(event)
@@ -8208,6 +8211,58 @@ class GatewayRunner:
logger.warning("MCP reload failed: %s", e)
return f"❌ MCP reload failed: {e}"
async def _handle_reload_skills_command(self, event: MessageEvent) -> str:
"""Handle /reload-skills — re-scan skills dir and clear prompt cache."""
loop = asyncio.get_running_loop()
try:
from agent.skill_commands import reload_skills
result = await loop.run_in_executor(None, reload_skills)
added = result.get("added", [])
removed = result.get("removed", [])
total = result.get("total", 0)
lines = ["🔄 **Skills Reloaded**\n"]
if added:
lines.append(f" Added: {', '.join(added)}")
if removed:
lines.append(f" Removed: {', '.join(removed)}")
if not added and not removed:
lines.append("No changes detected.")
lines.append(f"\n📚 {total} skill(s) available")
# Inject a session-history note so the model sees the new skill
# list on its next turn. Appended after all existing messages
# to preserve prompt-cache for the prefix.
change_parts = []
if added:
change_parts.append(f"Added skills: {', '.join(added)}")
if removed:
change_parts.append(f"Removed skills: {', '.join(removed)}")
if change_parts:
change_detail = ". ".join(change_parts) + ". "
reload_msg = {
"role": "user",
"content": (
f"[IMPORTANT: Skills have been reloaded. {change_detail}"
f"{total} skill(s) now available. Use skills_list to "
f"see the updated catalog.]"
),
}
try:
session_entry = self.session_store.get_or_create_session(event.source)
self.session_store.append_to_transcript(
session_entry.session_id, reload_msg
)
except Exception:
pass # Best-effort; don't fail the reload over a transcript write
return "\n".join(lines)
except Exception as e:
logger.warning("Skills reload failed: %s", e)
return f"❌ Skills reload failed: {e}"
# ------------------------------------------------------------------
# /approve & /deny — explicit dangerous-command approval
# ------------------------------------------------------------------