Agent Zero AI framework
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):