Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions agent/skill_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,71 @@ def get_skill_commands() -> Dict[str, Dict[str, Any]]:
return _skill_commands


def reload_skills() -> Dict[str, Any]:
"""Re-scan the skills directory and return a diff of what changed.

Rescans ``~/.hermes/skills/`` and any ``skills.external_dirs`` so the
slash-command map (``agent.skill_commands._skill_commands``) reflects
skills added or removed on disk.

This does NOT invalidate the skills system-prompt cache. Skills are
called by name via ``/skill-name``, ``skills_list``, or ``skill_view``
— they don't need to be in the system prompt for the model to use them.
Keeping the prompt cache intact preserves prefix caching across the
reload, so a user invoking ``/reload-skills`` pays no cache-reset cost.

Returns:
Dict with keys::

{
"added": [{"name": str, "description": str}, ...],
"removed": [{"name": str, "description": str}, ...],
"unchanged": [skill names present before and after],
"total": total skill count after rescan,
"commands": total /slash-skill count after rescan,
}

``description`` is the skill's full SKILL.md frontmatter
``description:`` field — the same string the system prompt renders
as `` - name: description`` for pre-existing skills.
"""
# Snapshot pre-reload state (name -> description) from the current
# slash-command cache. Using dicts lets the post-rescan diff carry
# descriptions for newly-visible or just-removed skills without a
# second disk walk.
def _snapshot(cmds: Dict[str, Dict[str, Any]]) -> Dict[str, str]:
out: Dict[str, str] = {}
for slash_key, info in cmds.items():
bare = slash_key.lstrip("/")
out[bare] = (info or {}).get("description") or ""
return out

before = _snapshot(_skill_commands)

# Rescan the skills dir. ``scan_skill_commands`` resets
# ``_skill_commands = {}`` internally and repopulates it.
new_commands = scan_skill_commands()

after = _snapshot(new_commands)

added_names = sorted(set(after) - set(before))
removed_names = sorted(set(before) - set(after))
unchanged = sorted(set(after) & set(before))

added = [{"name": n, "description": after[n]} for n in added_names]
# For removed skills, use the description we had cached pre-rescan
# (the skill file is gone so we can't re-read it).
removed = [{"name": n, "description": before[n]} for n in removed_names]

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.

Expand Down
84 changes: 84 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3107,6 +3107,8 @@ def _slow_command_status(self, command: str) -> str:
return "Processing skills command..."
if cmd_lower == "/reload-mcp":
return "Reloading MCP servers..."
if cmd_lower == "/reload-skills" or cmd_lower == "/reload_skills":
return "Reloading skills..."
if cmd_lower.startswith("/browser"):
return "Configuring browser..."
return "Processing command..."
Expand Down Expand Up @@ -6286,6 +6288,9 @@ def process_command(self, command: str) -> bool:
elif canonical == "reload-mcp":
with self._busy_command(self._slow_command_status(cmd_original)):
self._reload_mcp()
elif canonical == "reload-skills":
with self._busy_command(self._slow_command_status(cmd_original)):
self._reload_skills()
elif canonical == "browser":
self._handle_browser_command(cmd_original)
elif canonical == "plugins":
Expand Down Expand Up @@ -7497,6 +7502,78 @@ def _reload_mcp(self):
except Exception as e:
print(f" ❌ MCP reload failed: {e}")

def _reload_skills(self) -> None:
"""Reload skills: rescan ~/.hermes/skills/ and queue a note for the
next user turn.

Skills don't need to live in the system prompt for the model to use
them (they're invoked via ``/skill-name``, ``skills_list``, or
``skill_view`` at runtime), so this does NOT clear the prompt cache.
It rescans the slash-command map, prints the diff for the user, and
— if any skills were added or removed — queues a one-shot note that
gets prepended to the next user message. This preserves message
alternation (no phantom user turn injected out of band) and keeps
prompt caching intact.
"""
try:
from agent.skill_commands import reload_skills

if not self._command_running:
print("🔄 Reloading skills...")

result = reload_skills()
added = result.get("added", []) # [{"name", "description"}, ...]
removed = result.get("removed", []) # [{"name", "description"}, ...]
total = result.get("total", 0)

if not added and not removed:
print(" No new skills detected.")
print(f" 📚 {total} skill(s) available")
return

def _fmt_line(item: dict) -> str:
nm = item.get("name", "")
desc = item.get("description", "")
return f" - {nm}: {desc}" if desc else f" - {nm}"

if added:
print(" ➕ Added Skills:")
for item in added:
print(f" {_fmt_line(item)}")
if removed:
print(" ➖ Removed Skills:")
for item in removed:
print(f" {_fmt_line(item)}")
print(f" 📚 {total} skill(s) available")

# Queue a one-shot note for the NEXT user turn. The CLI's agent
# loop prepends ``_pending_skills_reload_note`` (if set) to the
# API-call-local message at ~L8770, then clears it — same
# pattern as ``_pending_model_switch_note``. Nothing is written
# to conversation_history here, so message alternation stays
# intact and no out-of-band user turn is persisted.
#
# Format matches how the system prompt renders pre-existing
# skills (`` - name: description``) so the model reads the
# diff in the same shape as its original skill catalog.
sections = ["[USER INITIATED SKILLS RELOAD:"]
if added:
sections.append("")
sections.append("Added Skills:")
for item in added:
sections.append(_fmt_line(item))
if removed:
sections.append("")
sections.append("Removed Skills:")
for item in removed:
sections.append(_fmt_line(item))
sections.append("")
sections.append("Use skills_list to see the updated catalog.]")
self._pending_skills_reload_note = "\n".join(sections)

except Exception as e:
print(f" ❌ Skills reload failed: {e}")

# ====================================================================
# Tool-call generation indicator (shown during streaming)
# ====================================================================
Expand Down Expand Up @@ -8705,6 +8782,13 @@ def run_agent():
if _msn:
agent_message = _msn + "\n\n" + agent_message
self._pending_model_switch_note = None
# Prepend pending /reload-skills note so the model sees which
# skills were added/removed before handling this turn. Same
# one-shot queue pattern as the model-switch note above.
_srn = getattr(self, '_pending_skills_reload_note', None)
if _srn:
agent_message = _srn + "\n\n" + agent_message
self._pending_skills_reload_note = None
try:
result = self.agent.run_conversation(
user_message=agent_message,
Expand Down
4 changes: 4 additions & 0 deletions gateway/platforms/discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -2270,6 +2270,10 @@ async def slash_insights(interaction: discord.Interaction, days: int = 7):
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=[
Expand Down
90 changes: 90 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -4219,6 +4219,9 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
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)

Expand Down Expand Up @@ -8208,6 +8211,82 @@ async def _handle_reload_mcp_command(self, event: MessageEvent) -> str:
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 — rescan skills dir, queue a note for next turn.

Skills don't need to be in the system prompt for the model to use
them (they're invoked via ``/skill-name``, ``skills_list``, or
``skill_view`` at runtime), so this does NOT clear the prompt cache
— prefix caching stays intact.

If any skills were added or removed, a one-shot note is queued on
``self._pending_skills_reload_notes[session_key]``. The gateway
prepends it to the NEXT user message in this session (see the
consumer at ~L11025 in ``_run_agent_turn``), then clears it. Nothing
is written to the session transcript out-of-band, so message
alternation is preserved.
"""
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", []) # [{"name", "description"}, ...]
removed = result.get("removed", []) # [{"name", "description"}, ...]
total = result.get("total", 0)

lines = ["🔄 **Skills Reloaded**\n"]
if not added and not removed:
lines.append("No new skills detected.")
lines.append(f"\n📚 {total} skill(s) available")
return "\n".join(lines)

def _fmt_line(item: dict) -> str:
nm = item.get("name", "")
desc = item.get("description", "")
return f" - {nm}: {desc}" if desc else f" - {nm}"

if added:
lines.append("➕ **Added Skills:**")
for item in added:
lines.append(_fmt_line(item))
if removed:
lines.append("➖ **Removed Skills:**")
for item in removed:
lines.append(_fmt_line(item))
lines.append(f"\n📚 {total} skill(s) available")

# Queue the one-shot note for the next user turn in this session.
# Format matches how the system prompt renders pre-existing
# skills (`` - name: description``) so the model reads the
# diff in the same shape as its original skill catalog.
sections = ["[USER INITIATED SKILLS RELOAD:"]
if added:
sections.append("")
sections.append("Added Skills:")
for item in added:
sections.append(_fmt_line(item))
if removed:
sections.append("")
sections.append("Removed Skills:")
for item in removed:
sections.append(_fmt_line(item))
sections.append("")
sections.append("Use skills_list to see the updated catalog.]")
note = "\n".join(sections)

session_key = self._session_key_for_source(event.source)
if not hasattr(self, "_pending_skills_reload_notes"):
self._pending_skills_reload_notes = {}
if session_key:
self._pending_skills_reload_notes[session_key] = note

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
# ------------------------------------------------------------------
Expand Down Expand Up @@ -10967,6 +11046,17 @@ def _approval_notify_sync(approval_data: dict) -> None:
+ message
)

# Consume one-shot /reload-skills note (if the user ran
# /reload-skills since their last turn in this session). Same
# queue pattern as CLI: prepend to the NEXT user message, then
# clear. Nothing was written to the transcript out-of-band, so
# message alternation stays intact.
_pending_notes = getattr(self, "_pending_skills_reload_notes", None)
if _pending_notes and session_key and session_key in _pending_notes:
_srn = _pending_notes.pop(session_key, None)
if _srn:
message = _srn + "\n\n" + message

_approval_session_key = session_key or ""
_approval_session_token = set_current_session_key(_approval_session_key)
register_gateway_notify(_approval_session_key, _approval_notify_sync)
Expand Down
2 changes: 2 additions & 0 deletions hermes_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ class CommandDef:
cli_only=True),
CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills",
aliases=("reload_mcp",)),
CommandDef("reload-skills", "Re-scan ~/.hermes/skills/ for newly installed or removed skills",
"Tools & Skills", aliases=("reload_skills",)),
CommandDef("browser", "Connect browser tools to your live Chrome via CDP", "Tools & Skills",
cli_only=True, args_hint="[connect|disconnect|status]",
subcommands=("connect", "disconnect", "status")),
Expand Down
Loading
Loading