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):