diff --git a/agent/skill_commands.py b/agent/skill_commands.py index 19c9b06c6c6..f7f2ace2374 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -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. diff --git a/cli.py b/cli.py index 714fd96ad51..912378a0081 100644 --- a/cli.py +++ b/cli.py @@ -3107,6 +3107,8 @@ class HermesCLI: 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..." @@ -6286,6 +6288,9 @@ class HermesCLI: 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": @@ -7497,6 +7502,67 @@ class HermesCLI: except Exception as e: print(f" āŒ MCP reload failed: {e}") + def _reload_skills(self) -> None: + """Reload skills: rescan ~/.hermes/skills/, clear prompt cache. + + 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. + """ + try: + from agent.skill_commands import reload_skills + + if not self._command_running: + print("šŸ”„ Reloading skills...") + + result = reload_skills() + added = result.get("added", []) + removed = result.get("removed", []) + 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(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 = [] + 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) + ". " + 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") + + except Exception as e: + print(f" āŒ Skills reload failed: {e}") + # ==================================================================== # Tool-call generation indicator (shown during streaming) # ==================================================================== diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 2e08d77c680..e90123c159d 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -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=[ diff --git a/gateway/run.py b/gateway/run.py index 22a46e393c8..f90f2d42877 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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 # ------------------------------------------------------------------ diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 7e3e14c5409..5ca562d87a2 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -155,6 +155,8 @@ COMMAND_REGISTRY: list[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")), diff --git a/tests/agent/test_skill_commands_reload.py b/tests/agent/test_skill_commands_reload.py new file mode 100644 index 00000000000..2e7f20e8714 --- /dev/null +++ b/tests/agent/test_skill_commands_reload.py @@ -0,0 +1,178 @@ +"""Tests for ``agent.skill_commands.reload_skills`` and the ``skills_reload`` tool. + +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. +""" + +import json +import shutil +import tempfile +import textwrap +from pathlib import Path + +import pytest + + +def _write_skill(skills_dir: Path, name: str, description: str = "") -> Path: + skill_dir = skills_dir / name + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_text( + textwrap.dedent( + f"""\ + --- + name: {name} + description: {description or f'{name} skill'} + --- + body + """ + ) + ) + return skill_dir + + +@pytest.fixture +def hermes_home(monkeypatch): + """Isolate HERMES_HOME for ``reload_skills`` tests. + + Rather than popping cache-bearing modules from ``sys.modules`` (which + races against pytest-xdist's parallel workers), we monkeypatch the + module-level ``HERMES_HOME`` / ``SKILLS_DIR`` constants in place so the + isolation is local to this fixture's scope. + """ + td = tempfile.mkdtemp(prefix="hermes-reload-skills-") + monkeypatch.setenv("HERMES_HOME", td) + home = Path(td) + (home / "skills").mkdir(parents=True, exist_ok=True) + + # Import lazily (inside fixture) so the modules are already resident, + # then redirect their captured paths at the new temp dir. + import tools.skills_tool as _st + import agent.skill_commands as _sc + + monkeypatch.setattr(_st, "HERMES_HOME", home, raising=False) + monkeypatch.setattr(_st, "SKILLS_DIR", home / "skills", raising=False) + # Reset the in-process slash-command cache so each test starts from zero. + monkeypatch.setattr(_sc, "_skill_commands", {}, raising=False) + + yield home + + shutil.rmtree(td, ignore_errors=True) + + +class TestReloadSkillsHelper: + """``agent.skill_commands.reload_skills``.""" + + def test_returns_expected_keys(self, hermes_home): + from agent.skill_commands import reload_skills + + result = reload_skills() + assert set(result) == {"added", "removed", "unchanged", "total", "commands"} + assert result["total"] == 0 + assert result["added"] == [] + assert result["removed"] == [] + + def test_detects_newly_added_skill(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") + result = reload_skills() + + assert result["added"] == ["demo"] + assert result["removed"] == [] + assert result["total"] == 1 + assert result["commands"] == 1 + + def test_detects_removed_skill(self, hermes_home): + from agent.skill_commands import reload_skills + + skill_dir = _write_skill(hermes_home / "skills", "demo") + # First reload: demo present + first = reload_skills() + assert first["total"] == 1 + + # Remove and reload + shutil.rmtree(skill_dir) + second = reload_skills() + + assert second["removed"] == ["demo"] + 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 + + snapshot = _skills_prompt_snapshot_path() + snapshot.parent.mkdir(parents=True, exist_ok=True) + snapshot.write_text("{}") + assert snapshot.exists() + + reload_skills() + + assert not snapshot.exists(), "prompt cache snapshot should be removed" + + def test_unchanged_skills_appear_in_unchanged_list(self, hermes_home): + from agent.skill_commands import reload_skills, get_skill_commands + + _write_skill(hermes_home / "skills", "alpha") + # Prime cache + get_skill_commands() + + # Call reload again with no FS changes + result = reload_skills() + assert "alpha" in result["unchanged"] + assert result["added"] == [] + assert result["removed"] == [] + + +class TestSkillsReloadTool: + """``tools.skills_tool.skills_reload`` — the agent-facing tool.""" + + def test_tool_returns_json(self, hermes_home): + from tools.skills_tool import skills_reload + + out = skills_reload() + result = json.loads(out) + assert result["success"] is True + assert set(result) == { + "success", + "added", + "removed", + "unchanged_count", + "total", + "commands", + } + + 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", []) == [] diff --git a/tests/cli/test_cli_reload_skills.py b/tests/cli/test_cli_reload_skills.py new file mode 100644 index 00000000000..32570e8bfc4 --- /dev/null +++ b/tests/cli/test_cli_reload_skills.py @@ -0,0 +1,77 @@ +"""Tests for the ``/reload-skills`` CLI slash command (``HermesCLI._reload_skills``).""" + +from unittest.mock import MagicMock, patch + + +def _make_cli(): + """Build a minimal HermesCLI shell exposing ``_reload_skills``.""" + import cli as cli_mod + + obj = object.__new__(cli_mod.HermesCLI) + obj._command_running = False + obj.conversation_history = [] + obj.agent = None + return obj + + +class TestReloadSkillsCLI: + def test_reports_added_and_removed(self, capsys): + cli = _make_cli() + with patch( + "agent.skill_commands.reload_skills", + return_value={ + "added": ["alpha", "beta"], + "removed": ["gamma"], + "unchanged": ["delta"], + "total": 3, + "commands": 3, + }, + ): + cli._reload_skills() + + out = capsys.readouterr().out + assert "Added: alpha, beta" in out + assert "Removed: gamma" 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): + cli = _make_cli() + with patch( + "agent.skill_commands.reload_skills", + return_value={ + "added": [], + "removed": [], + "unchanged": ["alpha"], + "total": 1, + "commands": 1, + }, + ): + cli._reload_skills() + + out = capsys.readouterr().out + assert "No changes detected" in out + assert "1 skill(s) available" in out + # Nothing changed — don't pollute history. + assert cli.conversation_history == [] + + def test_handles_reload_failure_gracefully(self, capsys): + cli = _make_cli() + with patch( + "agent.skill_commands.reload_skills", + side_effect=RuntimeError("boom"), + ): + cli._reload_skills() + + 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 == [] diff --git a/tests/gateway/test_reload_skills_command.py b/tests/gateway/test_reload_skills_command.py new file mode 100644 index 00000000000..c3b9b70881d --- /dev/null +++ b/tests/gateway/test_reload_skills_command.py @@ -0,0 +1,174 @@ +"""Tests for the ``/reload-skills`` gateway slash command handler. + +Verifies the gateway path that mirrors ``/reload-mcp``: + * 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 +""" + +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.config import GatewayConfig, Platform, PlatformConfig +from gateway.platforms.base import MessageEvent +from gateway.session import SessionEntry, SessionSource, build_session_key + + +def _make_source() -> SessionSource: + return SessionSource( + platform=Platform.TELEGRAM, + user_id="u1", + chat_id="c1", + user_name="tester", + chat_type="dm", + ) + + +def _make_event(text: str) -> MessageEvent: + return MessageEvent(text=text, source=_make_source(), message_id="m1") + + +def _make_runner(): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig( + platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")} + ) + adapter = MagicMock() + adapter.send = AsyncMock() + runner.adapters = {Platform.TELEGRAM: adapter} + runner._voice_mode = {} + runner.hooks = SimpleNamespace( + emit=AsyncMock(), + emit_collect=AsyncMock(return_value=[]), + loaded_hooks=False, + ) + + session_entry = SessionEntry( + session_key=build_session_key(_make_source()), + session_id="sess-1", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + ) + runner.session_store = MagicMock() + runner.session_store.get_or_create_session.return_value = session_entry + runner.session_store.load_transcript.return_value = [] + runner.session_store.has_any_sessions.return_value = True + runner.session_store.append_to_transcript = MagicMock() + runner.session_store.rewrite_transcript = MagicMock() + runner.session_store.update_session = MagicMock() + runner._running_agents = {} + runner._pending_messages = {} + runner._pending_approvals = {} + runner._session_db = None + runner._reasoning_config = None + runner._provider_routing = {} + runner._fallback_model = None + runner._show_reasoning = False + runner._is_user_authorized = lambda _source: True + runner._set_session_env = lambda _context: None + runner._should_send_voice_reply = lambda *_args, **_kwargs: False + 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 + + fake_result = { + "added": ["alpha", "beta"], + "removed": ["gamma"], + "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) + + runner = _make_runner() + out = await runner._handle_reload_skills_command(_make_event("/reload-skills")) + + assert out is not None + assert "Skills Reloaded" in out + assert "alpha" in out and "beta" in out + assert "gamma" 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"] + + +@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.""" + import agent.skill_commands as skill_commands_mod + + monkeypatch.setattr( + skill_commands_mod, + "reload_skills", + lambda: { + "added": [], + "removed": [], + "unchanged": ["alpha"], + "total": 1, + "commands": 1, + }, + ) + + runner = _make_runner() + out = await runner._handle_reload_skills_command(_make_event("/reload-skills")) + + assert "No changes 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() + + +@pytest.mark.asyncio +async def test_dispatcher_routes_reload_skills(monkeypatch): + """``/reload-skills`` must reach ``_handle_reload_skills_command``.""" + import gateway.run as gateway_run + + runner = _make_runner() + sentinel = "reload-skills handler reached" + runner._handle_reload_skills_command = AsyncMock(return_value=sentinel) # type: ignore[attr-defined] + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("/reload-skills")) + assert result == sentinel + + +@pytest.mark.asyncio +async def test_underscored_alias_not_flagged_unknown(monkeypatch): + """Telegram autocomplete sends ``/reload_skills`` for ``/reload-skills``.""" + import gateway.run as gateway_run + + runner = _make_runner() + runner._handle_reload_skills_command = AsyncMock(return_value="ok") # type: ignore[attr-defined] + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("/reload_skills")) + if result is not None: + assert "Unknown command" not in result diff --git a/tools/skills_tool.py b/tools/skills_tool.py index 538cb56e8e2..ea97007b3e2 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -1512,3 +1512,61 @@ registry.register( check_fn=check_skills_requirements, 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 a444713f576..d00b1a6f09c 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_list", "skill_view", "skill_manage", "skills_reload", # 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"], + "tools": ["skills_list", "skill_view", "skill_manage", "skills_reload"], "includes": [] }, @@ -279,7 +279,7 @@ TOOLSETS = { "terminal", "process", "read_file", "write_file", "patch", "search_files", "vision_analyze", - "skills_list", "skill_view", "skill_manage", + "skills_list", "skill_view", "skill_manage", "skills_reload", "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_list", "skill_view", "skill_manage", "skills_reload", # Browser automation "browser_navigate", "browser_snapshot", "browser_click", "browser_type", "browser_scroll", "browser_back",