diff --git a/agent/skill_commands.py b/agent/skill_commands.py index f7f2ace237..fff29eff66 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -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, diff --git a/cli.py b/cli.py index 912378a008..737bf49773 100644 --- a/cli.py +++ b/cli.py @@ -7503,11 +7503,17 @@ class HermesCLI: print(f" ❌ MCP reload failed: {e}") def _reload_skills(self) -> None: - """Reload skills: rescan ~/.hermes/skills/, clear prompt cache. + """Reload skills: rescan ~/.hermes/skills/ and queue a note for the + next user turn. - Mirrors the ``/reload-mcp`` UX. After rescanning, the system prompt - for the next turn is rebuilt with the fresh skill list and any - ``/skill-name`` slash commands are picked up immediately. + 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 @@ -7516,49 +7522,54 @@ class HermesCLI: print("🔄 Reloading skills...") result = reload_skills() - added = result.get("added", []) - removed = result.get("removed", []) + added = result.get("added", []) # [{"name", "description"}, ...] + removed = result.get("removed", []) # [{"name", "description"}, ...] total = result.get("total", 0) - if added: - print(f" ➕ Added: {', '.join(added)}") - if removed: - print(f" ➖ Removed: {', '.join(removed)}") if not added and not removed: - print(" No changes detected.") + 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") - # Inject a system-style note so the model sees the new skill - # list on its next turn. Appended at the end of history to - # preserve prompt-cache for the prefix. - change_parts = [] + # 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: - change_parts.append(f"Added skills: {', '.join(added)}") + sections.append("") + sections.append("Added Skills:") + for item in added: + sections.append(_fmt_line(item)) if removed: - change_parts.append(f"Removed skills: {', '.join(removed)}") - if change_parts: - change_detail = ". ".join(change_parts) + ". " - self.conversation_history.append({ - "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.]" - ), - }) - - # Persist immediately so the session log reflects the - # reload event. - if self.agent is not None: - try: - self.agent._persist_session( - self.conversation_history, - self.conversation_history, - ) - except Exception: - pass # Best-effort - - print(f" ✅ Skill cache cleared") + 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}") @@ -8771,6 +8782,13 @@ class HermesCLI: 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, diff --git a/gateway/run.py b/gateway/run.py index f90f2d4287..bffd25a086 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8212,50 +8212,74 @@ class GatewayRunner: 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.""" + """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", []) - removed = result.get("removed", []) + added = result.get("added", []) # [{"name", "description"}, ...] + removed = result.get("removed", []) # [{"name", "description"}, ...] 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("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") - # 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 = [] + # 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: - change_parts.append(f"Added skills: {', '.join(added)}") + sections.append("") + sections.append("Added Skills:") + for item in added: + sections.append(_fmt_line(item)) 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 + 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) @@ -11022,6 +11046,17 @@ class GatewayRunner: + 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) diff --git a/tests/agent/test_skill_commands_reload.py b/tests/agent/test_skill_commands_reload.py index 2e7f20e871..ee77141d19 100644 --- a/tests/agent/test_skill_commands_reload.py +++ b/tests/agent/test_skill_commands_reload.py @@ -1,11 +1,15 @@ -"""Tests for ``agent.skill_commands.reload_skills`` and the ``skills_reload`` tool. +"""Tests for ``agent.skill_commands.reload_skills``. -Covers the helper that powers ``/reload-skills`` (CLI + gateway slash command) -and the ``skills_reload`` agent tool — both clear in-process skill caches and -return a diff of newly-visible / removed skill names. +Covers the helper that powers ``/reload-skills`` (CLI + gateway slash command). +The helper rescans the skills directory and returns a diff of what changed. +It does NOT invalidate the skills system-prompt cache — skills are invoked +at runtime via ``/skill-name``, ``skills_list``, or ``skill_view`` and don't +need to live in the system prompt. + +``added`` and ``removed`` are lists of ``{"name": str, "description": str}`` +dicts. Descriptions are truncated to 60 chars. """ -import json import shutil import tempfile import textwrap @@ -72,49 +76,53 @@ class TestReloadSkillsHelper: assert result["added"] == [] assert result["removed"] == [] - def test_detects_newly_added_skill(self, hermes_home): + def test_detects_newly_added_skill_with_description(self, hermes_home): from agent.skill_commands import reload_skills, get_skill_commands # Prime the cache so subsequent diff is meaningful get_skill_commands() - _write_skill(hermes_home / "skills", "demo") + _write_skill(hermes_home / "skills", "demo", "a demo skill") result = reload_skills() - assert result["added"] == ["demo"] + assert result["added"] == [{"name": "demo", "description": "a demo skill"}] assert result["removed"] == [] assert result["total"] == 1 assert result["commands"] == 1 - def test_detects_removed_skill(self, hermes_home): + def test_detects_removed_skill_carries_description(self, hermes_home): from agent.skill_commands import reload_skills - skill_dir = _write_skill(hermes_home / "skills", "demo") + skill_dir = _write_skill(hermes_home / "skills", "demo", "soon to be gone") # First reload: demo present first = reload_skills() assert first["total"] == 1 + assert first["added"] == [{"name": "demo", "description": "soon to be gone"}] - # Remove and reload + # Remove and reload — the description must survive the removal diff + # (we cached it from the pre-rescan snapshot). shutil.rmtree(skill_dir) second = reload_skills() - assert second["removed"] == ["demo"] + assert second["removed"] == [{"name": "demo", "description": "soon to be gone"}] assert second["added"] == [] assert second["total"] == 0 - def test_clears_prompt_cache_snapshot(self, hermes_home): - """The disk snapshot at ``.skills_prompt_snapshot.json`` must be removed.""" - from agent.prompt_builder import _skills_prompt_snapshot_path - from agent.skill_commands import reload_skills + def test_description_passes_through_verbatim(self, hermes_home): + """``description`` must be the full SKILL.md frontmatter string — no + truncation. The system prompt renders skills as + `` - name: description`` without a length cap, and the reload + note mirrors that format, so truncating here would make the diff + render differently from the original catalog.""" + from agent.skill_commands import reload_skills, get_skill_commands - snapshot = _skills_prompt_snapshot_path() - snapshot.parent.mkdir(parents=True, exist_ok=True) - snapshot.write_text("{}") - assert snapshot.exists() + get_skill_commands() # prime + long_desc = "x" * 200 + _write_skill(hermes_home / "skills", "longdesc", long_desc) - reload_skills() - - assert not snapshot.exists(), "prompt cache snapshot should be removed" + result = reload_skills() + assert len(result["added"]) == 1 + assert result["added"][0]["description"] == long_desc def test_unchanged_skills_appear_in_unchanged_list(self, hermes_home): from agent.skill_commands import reload_skills, get_skill_commands @@ -129,50 +137,24 @@ class TestReloadSkillsHelper: assert result["added"] == [] assert result["removed"] == [] + def test_does_not_invalidate_prompt_cache_snapshot(self, hermes_home): + """reload_skills must NOT delete the skills prompt-cache snapshot. -class TestSkillsReloadTool: - """``tools.skills_tool.skills_reload`` — the agent-facing tool.""" + Skills are called at runtime — the system prompt doesn't need to + mention them for the model to use them — so reloading them should + preserve prefix caching. + """ + from agent.prompt_builder import _skills_prompt_snapshot_path + from agent.skill_commands import reload_skills - def test_tool_returns_json(self, hermes_home): - from tools.skills_tool import skills_reload + snapshot = _skills_prompt_snapshot_path() + snapshot.parent.mkdir(parents=True, exist_ok=True) + snapshot.write_text("{}") + assert snapshot.exists() - out = skills_reload() - result = json.loads(out) - assert result["success"] is True - assert set(result) == { - "success", - "added", - "removed", - "unchanged_count", - "total", - "commands", - } + reload_skills() - def test_tool_reports_added_skill(self, hermes_home): - from agent.skill_commands import get_skill_commands - from tools.skills_tool import skills_reload - - get_skill_commands() # prime cache - _write_skill(hermes_home / "skills", "freshly-added", "fresh skill") - - result = json.loads(skills_reload()) - assert result["success"] is True - assert result["added"] == ["freshly-added"] - assert result["total"] == 1 - - def test_tool_is_registered_in_skills_toolset(self, hermes_home): - # Importing the module triggers registry.register - import tools.skills_tool # noqa: F401 - from tools.registry import registry - - assert "skills_reload" in registry.get_tool_names_for_toolset("skills") - assert registry.get_toolset_for_tool("skills_reload") == "skills" - - def test_tool_schema_has_no_required_args(self, hermes_home): - import tools.skills_tool # noqa: F401 - from tools.registry import registry - - schema = registry.get_schema("skills_reload") - assert schema["name"] == "skills_reload" - # Caller invokes with no arguments; tool returns the diff verbatim. - assert schema["parameters"].get("required", []) == [] + assert snapshot.exists(), ( + "prompt cache snapshot should be preserved — skills don't live " + "in the system prompt so there's no reason to invalidate it" + ) diff --git a/tests/cli/test_cli_reload_skills.py b/tests/cli/test_cli_reload_skills.py index 32570e8bfc..1b728bc3c1 100644 --- a/tests/cli/test_cli_reload_skills.py +++ b/tests/cli/test_cli_reload_skills.py @@ -1,6 +1,14 @@ -"""Tests for the ``/reload-skills`` CLI slash command (``HermesCLI._reload_skills``).""" +"""Tests for the ``/reload-skills`` CLI slash command (``HermesCLI._reload_skills``). -from unittest.mock import MagicMock, patch +The CLI handler prints the diff (name + description) for the user and — +when any skills were added or removed — queues a one-shot note on +``self._pending_skills_reload_note``. The note is prepended to the NEXT +user message (see cli.py ~L8770, same pattern as +``_pending_model_switch_note``) and cleared after use, so no phantom user +turn is persisted to ``conversation_history``. +""" + +from unittest.mock import patch def _make_cli(): @@ -15,13 +23,18 @@ def _make_cli(): class TestReloadSkillsCLI: - def test_reports_added_and_removed(self, capsys): + def test_reports_added_and_removed_and_queues_note(self, capsys): cli = _make_cli() with patch( "agent.skill_commands.reload_skills", return_value={ - "added": ["alpha", "beta"], - "removed": ["gamma"], + "added": [ + {"name": "alpha", "description": "Run alpha to do xyz"}, + {"name": "beta", "description": "Run beta to do abc"}, + ], + "removed": [ + {"name": "gamma", "description": "Old removed skill"}, + ], "unchanged": ["delta"], "total": 3, "commands": 3, @@ -30,19 +43,28 @@ class TestReloadSkillsCLI: cli._reload_skills() out = capsys.readouterr().out - assert "Added: alpha, beta" in out - assert "Removed: gamma" in out + assert "Added Skills:" in out + assert "- alpha: Run alpha to do xyz" in out + assert "- beta: Run beta to do abc" in out + assert "Removed Skills:" in out + assert "- gamma: Old removed skill" in out assert "3 skill(s) available" in out - # An informational message should be appended to the conversation - # so the model picks up the diff on the next turn. - assert len(cli.conversation_history) == 1 - msg = cli.conversation_history[0] - assert msg["role"] == "user" - assert "Skills have been reloaded" in msg["content"] - assert "alpha" in msg["content"] - assert "gamma" in msg["content"] - def test_reports_no_changes(self, capsys): + # Must NOT pollute conversation_history — alternation-safe. + assert cli.conversation_history == [] + + # One-shot note queued with system-prompt-style formatting. + note = getattr(cli, "_pending_skills_reload_note", None) + assert note is not None + assert note.startswith("[USER INITIATED SKILLS RELOAD:") + assert note.endswith("Use skills_list to see the updated catalog.]") + assert "Added Skills:" in note + assert " - alpha: Run alpha to do xyz" in note + assert " - beta: Run beta to do abc" in note + assert "Removed Skills:" in note + assert " - gamma: Old removed skill" in note + + def test_reports_no_changes_and_queues_nothing(self, capsys): cli = _make_cli() with patch( "agent.skill_commands.reload_skills", @@ -57,10 +79,10 @@ class TestReloadSkillsCLI: cli._reload_skills() out = capsys.readouterr().out - assert "No changes detected" in out + assert "No new skills detected" in out assert "1 skill(s) available" in out - # Nothing changed — don't pollute history. assert cli.conversation_history == [] + assert getattr(cli, "_pending_skills_reload_note", None) is None def test_handles_reload_failure_gracefully(self, capsys): cli = _make_cli() @@ -73,5 +95,5 @@ class TestReloadSkillsCLI: out = capsys.readouterr().out assert "Skills reload failed" in out assert "boom" in out - # Failure must not append a misleading "skills reloaded" note. assert cli.conversation_history == [] + assert getattr(cli, "_pending_skills_reload_note", None) is None diff --git a/tests/gateway/test_reload_skills_command.py b/tests/gateway/test_reload_skills_command.py index c3b9b70881..5b9804bb1d 100644 --- a/tests/gateway/test_reload_skills_command.py +++ b/tests/gateway/test_reload_skills_command.py @@ -1,10 +1,16 @@ """Tests for the ``/reload-skills`` gateway slash command handler. -Verifies the gateway path that mirrors ``/reload-mcp``: +Verifies: * dispatcher routes ``/reload-skills`` to ``_handle_reload_skills_command`` * the underscored alias ``/reload_skills`` is not flagged as unknown * the handler invokes ``agent.skill_commands.reload_skills`` and renders a human-readable diff + * when any skills changed, a one-shot note is queued on + ``runner._pending_skills_reload_notes[session_key]`` (the agent loop + consumes and clears it on the next user turn — see ``gateway/run.py`` + near the ``_has_fresh_tool_tail`` block) + * the handler does NOT append to the session transcript out-of-band — + message alternation must not be broken by a phantom user turn """ from datetime import datetime @@ -75,48 +81,66 @@ def _make_runner(): runner._is_user_authorized = lambda _source: True runner._set_session_env = lambda _context: None runner._should_send_voice_reply = lambda *_args, **_kwargs: False + # Use the real _session_key_for_source binding so the key matches what + # the agent-loop consumer will look up later. + from gateway.run import GatewayRunner as _GR + runner._session_key_for_source = _GR._session_key_for_source.__get__(runner, _GR) return runner @pytest.mark.asyncio -async def test_reload_skills_handler_renders_added_and_removed(monkeypatch): - """The handler should call ``reload_skills`` and surface the diff.""" - import gateway.run as gateway_run - +async def test_reload_skills_handler_queues_note_on_diff(monkeypatch): + """Diff non-empty → handler queues a one-shot note and does NOT touch transcript.""" fake_result = { - "added": ["alpha", "beta"], - "removed": ["gamma"], + "added": [ + {"name": "alpha", "description": "Run alpha to do xyz"}, + {"name": "beta", "description": "Run beta to do abc"}, + ], + "removed": [ + {"name": "gamma", "description": "Old removed skill"}, + ], "unchanged": ["delta"], "total": 3, "commands": 3, } - def _fake_reload_skills(): - return fake_result - - # Patch the symbol where ``_handle_reload_skills_command`` imports it from. import agent.skill_commands as skill_commands_mod - monkeypatch.setattr(skill_commands_mod, "reload_skills", _fake_reload_skills) + monkeypatch.setattr(skill_commands_mod, "reload_skills", lambda: fake_result) runner = _make_runner() - out = await runner._handle_reload_skills_command(_make_event("/reload-skills")) + event = _make_event("/reload-skills") + out = await runner._handle_reload_skills_command(event) assert out is not None assert "Skills Reloaded" in out - assert "alpha" in out and "beta" in out - assert "gamma" in out + assert "Added Skills:" in out + assert "- alpha: Run alpha to do xyz" in out + assert "- beta: Run beta to do abc" in out + assert "Removed Skills:" in out + assert "- gamma: Old removed skill" in out assert "3 skill(s) available" in out - # A history note should be appended so the model sees the diff next turn. - runner.session_store.append_to_transcript.assert_called_once() - appended = runner.session_store.append_to_transcript.call_args[0][1] - assert appended["role"] == "user" - assert "Skills have been reloaded" in appended["content"] + # MUST NOT write to the session transcript — that would break alternation. + runner.session_store.append_to_transcript.assert_not_called() + + # MUST have queued a one-shot note keyed on the session. + pending = getattr(runner, "_pending_skills_reload_notes", None) + assert pending is not None + session_key = runner._session_key_for_source(event.source) + assert session_key in pending + note = pending[session_key] + assert note.startswith("[USER INITIATED SKILLS RELOAD:") + assert note.endswith("Use skills_list to see the updated catalog.]") + assert "Added Skills:" in note + assert " - alpha: Run alpha to do xyz" in note + assert " - beta: Run beta to do abc" in note + assert "Removed Skills:" in note + assert " - gamma: Old removed skill" in note @pytest.mark.asyncio async def test_reload_skills_handler_reports_no_changes(monkeypatch): - """When nothing changed, the handler should say so without injecting a note.""" + """No diff → no queued note, no transcript write.""" import agent.skill_commands as skill_commands_mod monkeypatch.setattr( @@ -134,10 +158,12 @@ async def test_reload_skills_handler_reports_no_changes(monkeypatch): runner = _make_runner() out = await runner._handle_reload_skills_command(_make_event("/reload-skills")) - assert "No changes detected" in out + assert "No new skills detected" in out assert "1 skill(s) available" in out - # No history note when nothing changed — preserves prompt cache. runner.session_store.append_to_transcript.assert_not_called() + # No queued note when nothing changed. + pending = getattr(runner, "_pending_skills_reload_notes", None) + assert not pending # None or empty dict @pytest.mark.asyncio diff --git a/tools/skills_tool.py b/tools/skills_tool.py index ea97007b3e..4ce338c59f 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -1513,60 +1513,3 @@ registry.register( emoji="📚", ) - -# --------------------------------------------------------------------------- -# skills_reload — rescan the skills dir without restarting the gateway -# --------------------------------------------------------------------------- - -def skills_reload(task_id: str | None = None) -> str: - """Re-scan ``~/.hermes/skills/`` and clear in-process skill caches. - - Use this after installing a skill via the shell during a session so the - new skill becomes visible to ``skills_list`` / ``skill_view`` and the - skill catalogue in the system prompt without a gateway restart. - - Returns: - JSON string with ``added``, ``removed``, ``unchanged``, ``total``, - and ``commands`` keys. ``added``/``removed`` are bare skill names - (no leading slash). - """ - try: - from agent.skill_commands import reload_skills as _reload - result = _reload() - except Exception as e: - return json.dumps({"success": False, "error": str(e)}) - - return json.dumps({ - "success": True, - "added": result.get("added", []), - "removed": result.get("removed", []), - "unchanged_count": len(result.get("unchanged", [])), - "total": result.get("total", 0), - "commands": result.get("commands", 0), - }) - - -SKILLS_RELOAD_SCHEMA = { - "name": "skills_reload", - "description": ( - "Re-scan the skills directory and clear in-process skill caches. " - "Use after installing or removing a skill mid-session (e.g. via the " - "shell tool or skills_hub) so the new skill becomes visible to " - "skills_list / skill_view without restarting the gateway. Returns " - "the diff of added/removed skill names plus the new total count." - ), - "parameters": { - "type": "object", - "properties": {}, - "required": [], - }, -} - -registry.register( - name="skills_reload", - toolset="skills", - schema=SKILLS_RELOAD_SCHEMA, - handler=lambda args, **kw: skills_reload(task_id=kw.get("task_id")), - check_fn=check_skills_requirements, - emoji="🔄", -) diff --git a/toolsets.py b/toolsets.py index d00b1a6f09..a444713f57 100644 --- a/toolsets.py +++ b/toolsets.py @@ -38,7 +38,7 @@ _HERMES_CORE_TOOLS = [ # Vision + image generation "vision_analyze", "image_generate", # Skills - "skills_list", "skill_view", "skill_manage", "skills_reload", + "skills_list", "skill_view", "skill_manage", # Browser automation "browser_navigate", "browser_snapshot", "browser_click", "browser_type", "browser_scroll", "browser_back", @@ -105,7 +105,7 @@ TOOLSETS = { "skills": { "description": "Access, create, edit, and manage skill documents with specialized instructions and knowledge", - "tools": ["skills_list", "skill_view", "skill_manage", "skills_reload"], + "tools": ["skills_list", "skill_view", "skill_manage"], "includes": [] }, @@ -279,7 +279,7 @@ TOOLSETS = { "terminal", "process", "read_file", "write_file", "patch", "search_files", "vision_analyze", - "skills_list", "skill_view", "skill_manage", "skills_reload", + "skills_list", "skill_view", "skill_manage", "browser_navigate", "browser_snapshot", "browser_click", "browser_type", "browser_scroll", "browser_back", "browser_press", "browser_get_images", @@ -303,7 +303,7 @@ TOOLSETS = { # Vision + image generation "vision_analyze", "image_generate", # Skills - "skills_list", "skill_view", "skill_manage", "skills_reload", + "skills_list", "skill_view", "skill_manage", # Browser automation "browser_navigate", "browser_snapshot", "browser_click", "browser_type", "browser_scroll", "browser_back",