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