From 5beba1ba4fbbd43a8fd1bcb2fae8f1744366b315 Mon Sep 17 00:00:00 2001 From: ppazosp Date: Thu, 25 Jun 2026 13:02:04 +0200 Subject: [PATCH] Omnio: refresh slash-command registry on skill create/edit/delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A skill created mid-session (e.g. via /learn, which authors through skill_manage) was not invocable until the gateway restarted: skill_manage cleared the skills system-prompt cache but never rescanned the slash-command registry (agent.skill_commands._skill_commands), which only refreshes on /reload-skills or a platform change. So GET /v1/skills returned the new skill with command=null and the chat path wouldn't expand /. Rescan the registry (scan_skill_commands) in skill_manage's success block for the actions that change the skill set or a skill's name — create/edit/patch/delete. write_file/remove_file touch only supporting files (not names), so they skip it. A rescan failure is logged, not silently swallowed. Tests: create -> /command resolves in get_skill_commands(); delete -> it's gone; write_file -> no rescan. Full skill_manager suite green (97), ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0184TxzJd1SYeBedsTLEfEwM --- tests/tools/test_skill_manager_tool.py | 65 ++++++++++++++++++++++++++ tools/skill_manager_tool.py | 17 +++++++ 2 files changed, 82 insertions(+) diff --git a/tests/tools/test_skill_manager_tool.py b/tests/tools/test_skill_manager_tool.py index 245ca9347576..2a9061978a9a 100644 --- a/tests/tools/test_skill_manager_tool.py +++ b/tests/tools/test_skill_manager_tool.py @@ -1028,3 +1028,68 @@ def test_out_of_tree_path_refused(self, tmp_path): assert result["success"] is False assert "skills root" in result["error"].lower() assert outside.exists() + + +# --------------------------------------------------------------------------- +# Slash-command registry refresh on skill_manage success +# --------------------------------------------------------------------------- + + +@contextmanager +def _registry_scans(tmp_path): + """Point BOTH the writer (skill_manage's SKILLS_DIR) and the slash-command + scanner (which `scan_skill_commands` pulls from `tools.skills_tool`) at the + temp dir, so a create/delete shows up in get_skill_commands() — the registry + the chat path and GET /v1/skills resolve ``/`` against.""" + with _skill_dir(tmp_path), \ + patch("tools.skills_tool.SKILLS_DIR", tmp_path), \ + patch("agent.skill_utils.get_external_skills_dirs", return_value=[]), \ + patch("tools.skills_tool._get_disabled_skill_names", return_value=set()): + yield + + +class TestRegistryRefreshOnMutation: + """A skill created/deleted mid-session must be immediately invocable as + ``/``. skill_manage rescans the cached slash-command registry, + which otherwise only refreshes on /reload-skills or a gateway restart — so + without this a just-created skill comes back command=null and can't be run.""" + + def test_create_makes_the_command_invocable(self, tmp_path): + from agent.skill_commands import get_skill_commands, scan_skill_commands + + with _registry_scans(tmp_path): + scan_skill_commands() + assert "/test-skill" not in get_skill_commands() + + raw = skill_manage(action="create", name="test-skill", content=VALID_SKILL_CONTENT) + assert json.loads(raw)["success"] is True + + # The fix: the create rescanned the registry, so it resolves now. + assert "/test-skill" in get_skill_commands() + + def test_delete_drops_the_command(self, tmp_path): + from agent.skill_commands import get_skill_commands + + with _registry_scans(tmp_path): + skill_manage(action="create", name="test-skill", content=VALID_SKILL_CONTENT) + assert "/test-skill" in get_skill_commands() + + raw = skill_manage(action="delete", name="test-skill") + assert json.loads(raw)["success"] is True + + assert "/test-skill" not in get_skill_commands() + + def test_supporting_file_write_does_not_rescan(self, tmp_path): + # write_file/remove_file touch only supporting files, not a skill's name, + # so they must NOT trigger the (relatively costly) registry rescan. + with _registry_scans(tmp_path): + skill_manage(action="create", name="test-skill", content=VALID_SKILL_CONTENT) + with patch("agent.skill_commands.scan_skill_commands") as mock_scan: + raw = skill_manage( + action="write_file", + name="test-skill", + file_path="references/extra.md", + file_content="# Extra\n", + ) + assert json.loads(raw)["success"] is True + assert not mock_scan.called diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index e3f48b2b6ea5..010d0eface51 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -1070,6 +1070,23 @@ def skill_manage( clear_skills_system_prompt_cache(clear_snapshot=True) except Exception: pass + # create/edit/patch/delete change the skill set or a skill's name, so the + # cached slash-command registry (agent.skill_commands._skill_commands) is now + # stale. Rescan it so the new/renamed/removed command is immediately invocable + # as / and reflected by GET /v1/skills — without it, a just-created + # skill comes back command=null and can't be invoked until the gateway + # restarts. (write_file/remove_file touch only supporting files, not names.) + if action in {"create", "edit", "patch", "delete"}: + try: + from agent.skill_commands import scan_skill_commands + scan_skill_commands() + except Exception: + logger.warning( + "skill_manage(%s): failed to rescan the slash-command registry; " + "the new/changed skill may not be invocable until reload", + action, + exc_info=True, + ) # Curator telemetry: bump patch_count on edit/patch/write_file (the actions # that mutate an existing skill's guidance), drop the record on delete. # Only mark a skill as agent-created when the background self-improvement