Fix skills selector unloading

Remove dynamically loaded skills when they are deactivated from the Skills selector. Treat skill names and paths as aliases so scoped defaults, chat overrides, and loaded-skill state resolve consistently.

Alessandro committed May 2, 2026 at 20:14 UTC d6d97d037cb39f198e17cffce85c39ba0d51c211
4 files changed +148 -21
helpers/skills.py
+72 -11
@@ -20,6 +20,7 @@ except Exception: # pragma: no cover
20
21 MAX_ACTIVE_SKILLS = 20
22 ACTIVE_SKILLS_PLUGIN_NAME = "_skills"
23 +AGENT_DATA_NAME_LOADED_SKILLS = "loaded_skills"
24 CONTEXT_DATA_NAME_CHAT_ACTIVE_SKILLS = "skills_chat_active"
25 CONTEXT_DATA_NAME_CHAT_DISABLED_SKILLS = "skills_chat_disabled"
26
@@ -703,6 +704,48 @@ def get_active_skills(agent: Agent | None) -> list[ActiveSkillEntry]:
704 return _build_active_skills(agent, limit=get_max_active_skills())
705
706
707 +def get_loaded_skill_entries(agent: Agent | None) -> list[ActiveSkillEntry]:
708 + if not agent:
709 + return []
710 +
711 + loaded = getattr(agent, "data", {}).get(AGENT_DATA_NAME_LOADED_SKILLS)
712 + if not isinstance(loaded, list):
713 + return []
714 +
715 + return [
716 + {"name": str(skill_name).strip()}
717 + for skill_name in loaded
718 + if str(skill_name).strip()
719 + ]
720 +
721 +
722 +def unload_agent_skill(agent: Agent | None, entry: Any) -> bool:
723 + normalized = _normalize_active_skill_entry(entry)
724 + if not agent or not normalized:
725 + return False
726 +
727 + data = getattr(agent, "data", None)
728 + if not isinstance(data, dict):
729 + return False
730 +
731 + loaded = data.get(AGENT_DATA_NAME_LOADED_SKILLS)
732 + if not isinstance(loaded, list):
733 + return False
734 +
735 + next_loaded: list[str] = []
736 + removed = False
737 + for skill_name in loaded:
738 + loaded_entry = _normalize_active_skill_entry(str(skill_name))
739 + if loaded_entry and _entries_match(loaded_entry, normalized):
740 + removed = True
741 + continue
742 + next_loaded.append(skill_name)
743 +
744 + if removed:
745 + data[AGENT_DATA_NAME_LOADED_SKILLS] = next_loaded
746 + return removed
747 +
748 +
749 def activate_chat_skill(agent: Agent, entry: Any) -> list[ActiveSkillEntry]:
750 normalized = _normalize_active_skill_entry(entry)
751 if not normalized:
@@ -712,18 +755,19 @@ def activate_chat_skill(agent: Agent, entry: Any) -> list[ActiveSkillEntry]:
755 if not context:
756 raise ValueError("A chat context is required.")
757
715 - key = _entry_key(normalized)
758 scope_entries = get_scope_active_skills(agent)
759 chat_entries = [
718 - item for item in get_chat_active_skills(context) if _entry_key(item) != key
760 + item
761 + for item in get_chat_active_skills(context)
762 + if not _entries_match(item, normalized)
763 ]
764 disabled_entries = [
765 item
766 for item in get_chat_disabled_skills(context)
723 - if _entry_key(item) != key
767 + if not _entries_match(item, normalized)
768 ]
769
726 - if not any(_entry_key(item) == key for item in scope_entries):
770 + if not any(_entries_match(item, normalized) for item in scope_entries):
771 chat_entries.append(normalized)
772
773 merged_entries = _build_active_skills(
@@ -759,18 +803,19 @@ def deactivate_chat_skill(agent: Agent, entry: Any) -> list[ActiveSkillEntry]:
803 if not context:
804 raise ValueError("A chat context is required.")
805
762 - key = _entry_key(normalized)
806 chat_entries = [
764 - item for item in get_chat_active_skills(context) if _entry_key(item) != key
807 + item
808 + for item in get_chat_active_skills(context)
809 + if not _entries_match(item, normalized)
810 ]
811 disabled_entries = [
812 item
813 for item in get_chat_disabled_skills(context)
769 - if _entry_key(item) != key
814 + if not _entries_match(item, normalized)
815 ]
816
817 is_scope_default = any(
773 - _entry_key(item) == key for item in get_scope_active_skills(agent)
818 + _entries_match(item, normalized) for item in get_scope_active_skills(agent)
819 )
820 if is_scope_default:
821 disabled_entries.append(normalized)
@@ -877,6 +922,19 @@ def _entry_key(entry: ActiveSkillEntry) -> str:
922 return str(entry.get("path") or entry.get("name") or "").strip().lower()
923
924
925 +def _entry_keys(entry: ActiveSkillEntry) -> set[str]:
926 + keys: set[str] = set()
927 + for value in (entry.get("path"), entry.get("name")):
928 + key = str(value or "").strip().lower()
929 + if key:
930 + keys.add(key)
931 + return keys
932 +
933 +
934 +def _entries_match(left: ActiveSkillEntry, right: ActiveSkillEntry) -> bool:
935 + return bool(_entry_keys(left) & _entry_keys(right))
936 +
937 +
938 def _get_agent_project_name(agent: Agent | None) -> str:
939 context = getattr(agent, "context", None)
940 if not context:
@@ -925,14 +983,17 @@ def _merge_active_skill_entries(
983 ) -> list[ActiveSkillEntry]:
984 merged: list[ActiveSkillEntry] = []
985 seen: set[str] = set()
928 - disabled_keys = {_entry_key(entry) for entry in disabled_entries if _entry_key(entry)}
986 + disabled_keys = {
987 + key for entry in disabled_entries for key in _entry_keys(entry)
988 + }
989
990 for entry in [*scope_entries, *dynamic_entries]:
991 + keys = _entry_keys(entry)
992 key = _entry_key(entry)
932 - if not key or key in seen or key in disabled_keys:
993 + if not key or keys & seen or keys & disabled_keys:
994 continue
995
935 - seen.add(key)
996 + seen.update(keys)
997 merged.append(entry)
998 if limit is not None and limit >= 0 and len(merged) >= limit:
999 break
plugins/_skills/api/skills_catalog.py
+2 -9
@@ -41,6 +41,7 @@ class SkillsCatalog(ApiHandler):
41 context = self._require_context(context_id)
42 skill_entry = self._require_skill_entry(input)
43 skills.deactivate_chat_skill(context.get_agent(), skill_entry)
44 + skills.unload_agent_skill(context.get_agent(), skill_entry)
45 save_tmp_chat(context)
46 return self._build_state(context_id=context.id)
47
@@ -198,15 +199,7 @@ class SkillsCatalog(ApiHandler):
199 if not agent:
200 return []
201
201 - loaded = getattr(agent, "data", {}).get("loaded_skills")
202 - if not isinstance(loaded, list):
203 - return []
204 -
205 - return [
206 - {"name": str(skill_name).strip()}
207 - for skill_name in loaded
208 - if str(skill_name).strip()
209 - ]
202 + return skills.get_loaded_skill_entries(agent)
203
204 def _merge_entries(
205 self,
tests/test_skills_runtime.py
+73
@@ -136,6 +136,79 @@ def test_chat_activation_can_override_scope_defaults(monkeypatch):
136 assert runtime.get_chat_disabled_skills(agent.context) == []
137
138
139 +def test_chat_deactivation_hides_name_only_scope_default_by_path(monkeypatch):
140 + monkeypatch.setattr(
141 + runtime.plugin_helpers,
142 + "get_plugin_config",
143 + lambda *args, **kwargs: _scope_config([{"name": "Pinned"}]),
144 + )
145 + agent = DummyAgent()
146 +
147 + runtime.deactivate_chat_skill(
148 + agent,
149 + {"name": "Pinned", "path": "/a0/usr/skills/custom/pinned"},
150 + )
151 +
152 + assert runtime.get_active_skills(agent) == []
153 + assert runtime.get_chat_disabled_skills(agent.context) == [
154 + {"name": "Pinned", "path": "/a0/usr/skills/custom/pinned"}
155 + ]
156 +
157 +
158 +def test_reactivating_name_only_scope_default_by_path_clears_hidden_override(monkeypatch):
159 + monkeypatch.setattr(
160 + runtime.plugin_helpers,
161 + "get_plugin_config",
162 + lambda *args, **kwargs: _scope_config([{"name": "Pinned"}]),
163 + )
164 + agent = DummyAgent()
165 +
166 + runtime.deactivate_chat_skill(
167 + agent,
168 + {"name": "Pinned", "path": "/a0/usr/skills/custom/pinned"},
169 + )
170 + runtime.activate_chat_skill(
171 + agent,
172 + {"name": "Pinned", "path": "/a0/usr/skills/custom/pinned"},
173 + )
174 +
175 + assert runtime.get_active_skills(agent) == [{"name": "Pinned"}]
176 + assert runtime.get_chat_active_skills(agent.context) == []
177 + assert runtime.get_chat_disabled_skills(agent.context) == []
178 +
179 +
180 +def test_loaded_skill_entries_come_from_agent_data():
181 + agent = DummyAgent()
182 + agent.data[runtime.AGENT_DATA_NAME_LOADED_SKILLS] = [
183 + "computer-use-remote",
184 + "",
185 + "a0-development",
186 + ]
187 +
188 + assert runtime.get_loaded_skill_entries(agent) == [
189 + {"name": "computer-use-remote"},
190 + {"name": "a0-development"},
191 + ]
192 +
193 +
194 +def test_unload_agent_skill_removes_loaded_skill_by_name():
195 + agent = DummyAgent()
196 + agent.data[runtime.AGENT_DATA_NAME_LOADED_SKILLS] = [
197 + "computer-use-remote",
198 + "a0-development",
199 + ]
200 +
201 + removed = runtime.unload_agent_skill(
202 + agent,
203 + {"name": "computer-use-remote", "path": "/a0/skills/computer-use-remote"},
204 + )
205 +
206 + assert removed is True
207 + assert agent.data[runtime.AGENT_DATA_NAME_LOADED_SKILLS] == [
208 + "a0-development"
209 + ]
210 +
211 +
212 def test_clearing_chat_overrides_restores_scope_defaults(monkeypatch):
213 monkeypatch.setattr(
214 runtime.plugin_helpers,
tools/skills_tool.py
+1 -1
@@ -7,7 +7,7 @@ from helpers import skills as skills_helper
7 from helpers.print_style import PrintStyle
8
9
10 -DATA_NAME_LOADED_SKILLS = "loaded_skills"
10 +DATA_NAME_LOADED_SKILLS = skills_helper.AGENT_DATA_NAME_LOADED_SKILLS
11
12
13 class SkillsTool(Tool):