refactor(reload-skills): queue note for next turn, drop cache invalidation + agent tool

Salvage-follow-up to @shannonsands's /reload-skills PR. Trims the feature to
match the design: user-initiated rescan, no prompt-cache reset, no new
schema surface, no phantom user turn, and the next-turn note carries each
added/removed skill's 60-char description (not just its name).

Changes vs the original PR:

* Drop the in-process skills prompt-cache clear in reload_skills(). Skills
  are invoked at runtime via /skill-name, skills_list, or skill_view —
  they don't need to live in the system prompt for the model to use them.
  Keeping the cache intact preserves prefix caching across the reload so
  /reload-skills pays no cache-reset cost. (MCP has to break the cache
  because tool schemas must be known at conversation start; skills do not.)

* Drop the skills_reload agent tool and SKILLS_RELOAD_SCHEMA from
  tools/skills_tool.py, plus the four skills_reload enumerations in
  toolsets.py. No new schema surface — agents can already see a freshly-
  installed skill via skill_view / skills_list the moment it's on disk.

* Replace the phantom 'role: user' turn injection with a one-shot queued
  note. CLI uses self._pending_skills_reload_note (same pattern as
  _pending_model_switch_note, prepended to the next API call and cleared).
  Gateway uses self._pending_skills_reload_notes[session_key]. The note
  is prepended to the NEXT real user message in this session, so message
  alternation stays intact and nothing out-of-band is persisted to the
  transcript.

* reload_skills() now returns added/removed as
  [{'name': str, 'description': str}, ...] (description truncated to 60
  chars — matches the curator / gateway adapter budget). The injected
  next-turn note formats each entry as 'name — description' so the model
  can actually reason about which new skills to call without running
  skills_list first.

* Only emit the note when the diff is non-empty. On empty diff, print
  'No new skills detected' and do nothing else.

* Tests rewritten to cover the queue semantics, the description payload,
  and a regression guard that the prompt-cache snapshot is preserved.
This commit is contained in:
teknium1
2026-04-29 20:39:15 -07:00
committed by Teknium
parent 7966560fb5
commit dd2d1ba5e6
8 changed files with 304 additions and 277 deletions

View File

@@ -285,59 +285,60 @@ def get_skill_commands() -> Dict[str, Dict[str, Any]]:
def reload_skills() -> Dict[str, Any]:
"""Re-scan the skills directory and invalidate every in-process skill cache.
"""Re-scan the skills directory and return a diff of what changed.
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.
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.
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
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": [skill names newly visible],
"removed": [skill names no longer visible],
"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,
}
"""
# 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``.
``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()
# 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 = _snapshot(new_commands)
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_names = sorted(set(after) - set(before))
removed_names = sorted(set(before) - set(after))
unchanged = sorted(set(after) & set(before))
added = sorted(_strip(after - before))
removed = sorted(_strip(before - after))
unchanged = sorted(_strip(after & 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,