Improve active skills management and simplify Skills UI
Unify skill handling layer and raise the active skills cap to 20. The Skills UI now presents a simpler checklist-style flow for selecting active skills, with live chat activation and saved defaults using the same visible list. Skill contents can be opened in a read-only Ace viewer via the existing markdown modal.
Alessandro committed
Apr 21, 2026 at 05:47 UTC
79f948b0769c3a5a47d1fbe877df6bef0d646807
16 files changed
+1469
-565
helpers/skills.py
+481
-1
@@ -4,9 +4,10 @@ import os
4
import re
5
from dataclasses import dataclass, field
6
from pathlib import Path
7
-from typing import Any, Dict, Iterable, List, Literal, Optional, Tuple, TYPE_CHECKING
7
+from typing import Any, Dict, Iterable, List, Literal, Optional, Tuple, TYPE_CHECKING, TypedDict
8
9
from helpers import files, subagents, projects, file_tree, runtime
10
+from helpers import plugins as plugin_helpers
11
12
if TYPE_CHECKING:
13
from agent import Agent
@@ -17,6 +18,24 @@ except Exception: # pragma: no cover
18
yaml = None # type: ignore
19
20
21
+MAX_ACTIVE_SKILLS = 20
22
+ACTIVE_SKILLS_PLUGIN_NAME = "_skills"
23
+CONTEXT_DATA_NAME_CHAT_ACTIVE_SKILLS = "skills_chat_active"
24
+CONTEXT_DATA_NAME_CHAT_DISABLED_SKILLS = "skills_chat_disabled"
25
+
26
+
27
+class ActiveSkillEntry(TypedDict, total=False):
28
+ name: str
29
+ path: str
30
+
31
+
32
+class CatalogSkill(TypedDict):
33
+ name: str
34
+ description: str
35
+ path: str
36
+ origin: str
37
+
38
+
39
@dataclass(slots=True)
40
class Skill:
41
name: str
@@ -544,3 +563,464 @@ def validate_skill_md(skill_md_path: Path) -> List[str]:
563
if not skill:
564
return ["Unable to parse SKILL.md frontmatter"]
565
return validate_skill(skill)
566
+
567
+
568
+def get_max_active_skills() -> int:
569
+ return MAX_ACTIVE_SKILLS
570
+
571
+
572
+def normalize_skills_config(config: dict[str, Any] | None) -> dict[str, Any]:
573
+ normalized = dict(config or {})
574
+ normalized["active_skills"] = normalize_active_skills(
575
+ normalized.get("active_skills")
576
+ )
577
+ return normalized
578
+
579
+
580
+def normalize_active_skills(raw: Any) -> list[ActiveSkillEntry]:
581
+ if not isinstance(raw, list):
582
+ return []
583
+
584
+ normalized: list[ActiveSkillEntry] = []
585
+ seen: set[str] = set()
586
+
587
+ for item in raw:
588
+ entry = _normalize_active_skill_entry(item)
589
+ if not entry:
590
+ continue
591
+
592
+ key = _entry_key(entry)
593
+ if not key or key in seen:
594
+ continue
595
+
596
+ seen.add(key)
597
+ normalized.append(entry)
598
+ if len(normalized) >= get_max_active_skills():
599
+ break
600
+
601
+ return normalized
602
+
603
+
604
+def list_skill_catalog(
605
+ project_name: str = "",
606
+ agent: Agent | None = None,
607
+) -> list[CatalogSkill]:
608
+ if not project_name:
609
+ project_name = _get_agent_project_name(agent)
610
+
611
+ catalog: list[CatalogSkill] = []
612
+ seen_paths: set[str] = set()
613
+
614
+ for root in _get_catalog_roots(project_name=project_name, agent=agent):
615
+ root_path = Path(root)
616
+ for skill_md in discover_skill_md_files(root_path):
617
+ skill = skill_from_markdown(skill_md, include_content=False)
618
+ if not skill:
619
+ continue
620
+
621
+ runtime_path = files.normalize_a0_path(str(skill.path))
622
+ if runtime_path in seen_paths:
623
+ continue
624
+
625
+ seen_paths.add(runtime_path)
626
+ catalog.append(
627
+ {
628
+ "name": skill.name or skill.path.name,
629
+ "description": skill.description or "",
630
+ "path": runtime_path,
631
+ "origin": _get_skill_origin(
632
+ runtime_path,
633
+ project_name=project_name,
634
+ ),
635
+ }
636
+ )
637
+
638
+ catalog.sort(key=lambda item: (item["name"].lower(), item["path"]))
639
+ return catalog
640
+
641
+
642
+def get_scope_active_skills(agent: Agent | None) -> list[ActiveSkillEntry]:
643
+ if not agent:
644
+ return []
645
+
646
+ project_name = _get_agent_project_name(agent)
647
+ config = (
648
+ plugin_helpers.get_plugin_config(
649
+ ACTIVE_SKILLS_PLUGIN_NAME,
650
+ agent=agent,
651
+ project_name=project_name,
652
+ agent_profile="",
653
+ )
654
+ or {}
655
+ )
656
+ return normalize_active_skills(config.get("active_skills"))
657
+
658
+
659
+def get_chat_active_skills(context: Any | None) -> list[ActiveSkillEntry]:
660
+ if not context:
661
+ return []
662
+ return normalize_active_skills(context.get_data(CONTEXT_DATA_NAME_CHAT_ACTIVE_SKILLS))
663
+
664
+
665
+def get_chat_disabled_skills(context: Any | None) -> list[ActiveSkillEntry]:
666
+ if not context:
667
+ return []
668
+ return normalize_active_skills(
669
+ context.get_data(CONTEXT_DATA_NAME_CHAT_DISABLED_SKILLS)
670
+ )
671
+
672
+
673
+def _build_active_skills(
674
+ agent: Agent | None,
675
+ *,
676
+ chat_entries: list[ActiveSkillEntry] | None = None,
677
+ disabled_entries: list[ActiveSkillEntry] | None = None,
678
+ limit: int | None = None,
679
+) -> list[ActiveSkillEntry]:
680
+ if not agent:
681
+ return []
682
+
683
+ context = getattr(agent, "context", None)
684
+ effective_limit = get_max_active_skills() if limit is None else limit
685
+ scope_entries = get_scope_active_skills(agent)
686
+ current_chat_entries = list(
687
+ chat_entries if chat_entries is not None else get_chat_active_skills(context)
688
+ )
689
+ current_disabled_entries = list(
690
+ disabled_entries
691
+ if disabled_entries is not None
692
+ else get_chat_disabled_skills(context)
693
+ )
694
+ return _merge_active_skill_entries(
695
+ scope_entries,
696
+ current_chat_entries,
697
+ current_disabled_entries,
698
+ limit=effective_limit,
699
+ )
700
+
701
+
702
+def get_active_skills(agent: Agent | None) -> list[ActiveSkillEntry]:
703
+ return _build_active_skills(agent, limit=get_max_active_skills())
704
+
705
+
706
+def activate_chat_skill(agent: Agent, entry: Any) -> list[ActiveSkillEntry]:
707
+ normalized = _normalize_active_skill_entry(entry)
708
+ if not normalized:
709
+ raise ValueError("A skill name or path is required.")
710
+
711
+ context = getattr(agent, "context", None)
712
+ if not context:
713
+ raise ValueError("A chat context is required.")
714
+
715
+ key = _entry_key(normalized)
716
+ scope_entries = get_scope_active_skills(agent)
717
+ chat_entries = [
718
+ item for item in get_chat_active_skills(context) if _entry_key(item) != key
719
+ ]
720
+ disabled_entries = [
721
+ item
722
+ for item in get_chat_disabled_skills(context)
723
+ if _entry_key(item) != key
724
+ ]
725
+
726
+ if not any(_entry_key(item) == key for item in scope_entries):
727
+ chat_entries.append(normalized)
728
+
729
+ merged_entries = _build_active_skills(
730
+ agent,
731
+ chat_entries=chat_entries,
732
+ disabled_entries=disabled_entries,
733
+ limit=-1,
734
+ )
735
+ if len(merged_entries) > get_max_active_skills():
736
+ raise ValueError(
737
+ f"You can activate at most {get_max_active_skills()} skills."
738
+ )
739
+
740
+ _store_context_active_skill_entries(
741
+ context,
742
+ CONTEXT_DATA_NAME_CHAT_ACTIVE_SKILLS,
743
+ chat_entries,
744
+ )
745
+ _store_context_active_skill_entries(
746
+ context,
747
+ CONTEXT_DATA_NAME_CHAT_DISABLED_SKILLS,
748
+ disabled_entries,
749
+ )
750
+ return get_active_skills(agent)
751
+
752
+
753
+def deactivate_chat_skill(agent: Agent, entry: Any) -> list[ActiveSkillEntry]:
754
+ normalized = _normalize_active_skill_entry(entry)
755
+ if not normalized:
756
+ raise ValueError("A skill name or path is required.")
757
+
758
+ context = getattr(agent, "context", None)
759
+ if not context:
760
+ raise ValueError("A chat context is required.")
761
+
762
+ key = _entry_key(normalized)
763
+ chat_entries = [
764
+ item for item in get_chat_active_skills(context) if _entry_key(item) != key
765
+ ]
766
+ disabled_entries = [
767
+ item
768
+ for item in get_chat_disabled_skills(context)
769
+ if _entry_key(item) != key
770
+ ]
771
+
772
+ is_scope_default = any(
773
+ _entry_key(item) == key for item in get_scope_active_skills(agent)
774
+ )
775
+ if is_scope_default:
776
+ disabled_entries.append(normalized)
777
+
778
+ _store_context_active_skill_entries(
779
+ context,
780
+ CONTEXT_DATA_NAME_CHAT_ACTIVE_SKILLS,
781
+ chat_entries,
782
+ )
783
+ _store_context_active_skill_entries(
784
+ context,
785
+ CONTEXT_DATA_NAME_CHAT_DISABLED_SKILLS,
786
+ disabled_entries,
787
+ )
788
+ return get_active_skills(agent)
789
+
790
+
791
+def clear_chat_skill_overrides(agent: Agent) -> list[ActiveSkillEntry]:
792
+ context = getattr(agent, "context", None)
793
+ if not context:
794
+ raise ValueError("A chat context is required.")
795
+
796
+ _store_context_active_skill_entries(context, CONTEXT_DATA_NAME_CHAT_ACTIVE_SKILLS, [])
797
+ _store_context_active_skill_entries(context, CONTEXT_DATA_NAME_CHAT_DISABLED_SKILLS, [])
798
+ return get_active_skills(agent)
799
+
800
+
801
+def build_active_skills_prompt(agent: Agent | None) -> str:
802
+ items = _resolve_active_skill_entries(agent, get_active_skills(agent))
803
+ return "\n\n".join(item["content"] for item in items if item.get("content")).strip()
804
+
805
+
806
+def _format_skill_prompt(skill: Skill) -> str:
807
+ lines = [
808
+ f"Skill: {skill.name or skill.path.name}",
809
+ f"Path: {files.normalize_a0_path(str(skill.path))}",
810
+ ]
811
+
812
+ if skill.description:
813
+ lines.extend(["", "Description:", skill.description.strip()])
814
+
815
+ lines.extend(["", "Instructions:", (skill.content or "").strip() or "(empty)"])
816
+ return "\n".join(lines)
817
+
818
+
819
+def _get_skill_origin(skill_path: str, project_name: str = "") -> str:
820
+ abs_path = files.fix_dev_path(skill_path)
821
+
822
+ if project_name:
823
+ project_root = projects.get_project_meta(project_name, "skills")
824
+ if files.exists(project_root) and files.is_in_dir(abs_path, project_root):
825
+ return "Project"
826
+
827
+ user_root = files.get_abs_path("usr", "skills")
828
+ if files.exists(user_root) and files.is_in_dir(abs_path, user_root):
829
+ return "User"
830
+
831
+ normalized_path = files.normalize_a0_path(abs_path)
832
+ if "/usr/plugins/" in normalized_path:
833
+ return "Community plugin"
834
+ if "/plugins/" in normalized_path:
835
+ return "Built-in plugin"
836
+ return "Built-in"
837
+
838
+
839
+def _normalize_active_skill_entry(item: Any) -> ActiveSkillEntry | None:
840
+ if isinstance(item, str):
841
+ stripped = item.strip()
842
+ if not stripped:
843
+ return None
844
+ if "/" in stripped:
845
+ return {"path": _normalize_active_skill_path(stripped)}
846
+ return {"name": stripped}
847
+
848
+ if not isinstance(item, dict):
849
+ return None
850
+
851
+ name = str(item.get("name") or "").strip()
852
+ path = str(item.get("path") or "").strip()
853
+
854
+ if path:
855
+ path = _normalize_active_skill_path(path)
856
+ if not (path or name):
857
+ return None
858
+
859
+ entry: ActiveSkillEntry = {}
860
+ if name:
861
+ entry["name"] = name
862
+ if path:
863
+ entry["path"] = path
864
+ return entry
865
+
866
+
867
+def _normalize_active_skill_path(path: str) -> str:
868
+ fixed = path.strip().replace("\\", "/")
869
+ if fixed.startswith("/a0/"):
870
+ return fixed.rstrip("/")
871
+ if fixed.startswith("/"):
872
+ return files.normalize_a0_path(fixed).rstrip("/")
873
+ return files.normalize_a0_path(files.get_abs_path(fixed)).rstrip("/")
874
+
875
+
876
+def _entry_key(entry: ActiveSkillEntry) -> str:
877
+ return str(entry.get("path") or entry.get("name") or "").strip().lower()
878
+
879
+
880
+def _get_agent_project_name(agent: Agent | None) -> str:
881
+ context = getattr(agent, "context", None)
882
+ if not context:
883
+ return ""
884
+ return projects.get_context_project_name(context) or ""
885
+
886
+
887
+def _get_catalog_roots(
888
+ project_name: str = "",
889
+ agent: Agent | None = None,
890
+) -> list[str]:
891
+ roots: list[str] = []
892
+ seen: set[str] = set()
893
+
894
+ def add(path: str) -> None:
895
+ if not path:
896
+ return
897
+ fixed = files.fix_dev_path(path)
898
+ if not files.exists(fixed) or fixed in seen:
899
+ return
900
+ seen.add(fixed)
901
+ roots.append(fixed)
902
+
903
+ if agent is not None:
904
+ for path in get_skill_roots(agent):
905
+ add(path)
906
+ return roots
907
+
908
+ if project_name:
909
+ add(projects.get_project_meta(project_name, "skills"))
910
+
911
+ add(files.get_abs_path("usr", "skills"))
912
+ for path in plugin_helpers.get_enabled_plugin_paths(None, "skills"):
913
+ add(path)
914
+ add(files.get_abs_path("skills"))
915
+
916
+ return roots
917
+
918
+
919
+def _merge_active_skill_entries(
920
+ scope_entries: list[ActiveSkillEntry],
921
+ dynamic_entries: list[ActiveSkillEntry],
922
+ disabled_entries: list[ActiveSkillEntry],
923
+ *,
924
+ limit: int | None,
925
+) -> list[ActiveSkillEntry]:
926
+ merged: list[ActiveSkillEntry] = []
927
+ seen: set[str] = set()
928
+ disabled_keys = {_entry_key(entry) for entry in disabled_entries if _entry_key(entry)}
929
+
930
+ for entry in [*scope_entries, *dynamic_entries]:
931
+ key = _entry_key(entry)
932
+ if not key or key in seen or key in disabled_keys:
933
+ continue
934
+
935
+ seen.add(key)
936
+ merged.append(entry)
937
+ if limit is not None and limit >= 0 and len(merged) >= limit:
938
+ break
939
+
940
+ return merged
941
+
942
+
943
+def _store_context_active_skill_entries(
944
+ context: Any,
945
+ key: str,
946
+ entries: list[ActiveSkillEntry],
947
+) -> None:
948
+ normalized_entries = normalize_active_skills(entries)
949
+ context.set_data(key, normalized_entries or None)
950
+
951
+
952
+def _resolve_active_skill_entries(
953
+ agent: Agent | None,
954
+ entries: list[ActiveSkillEntry],
955
+) -> list[dict[str, str]]:
956
+ if not agent:
957
+ return []
958
+
959
+ visible_roots = [files.fix_dev_path(root) for root in get_skill_roots(agent)]
960
+ resolved: list[dict[str, str]] = []
961
+ seen_paths: set[str] = set()
962
+
963
+ for entry in entries:
964
+ skill = _resolve_active_skill_entry(entry, visible_roots)
965
+ if not skill:
966
+ continue
967
+
968
+ runtime_path = files.normalize_a0_path(str(skill.path))
969
+ if runtime_path in seen_paths:
970
+ continue
971
+
972
+ seen_paths.add(runtime_path)
973
+ resolved.append(
974
+ {
975
+ "name": skill.name or skill.path.name,
976
+ "path": runtime_path,
977
+ "content": _format_skill_prompt(skill),
978
+ }
979
+ )
980
+
981
+ return resolved
982
+
983
+
984
+def _resolve_active_skill_entry(
985
+ entry: ActiveSkillEntry,
986
+ visible_roots: list[str],
987
+) -> Skill | None:
988
+ skill_path = str(entry.get("path") or "").strip()
989
+ if skill_path:
990
+ skill = _load_skill_from_runtime_path(skill_path, visible_roots)
991
+ if skill:
992
+ return skill
993
+
994
+ skill_name = str(entry.get("name") or "").strip()
995
+ if not skill_name:
996
+ return None
997
+
998
+ target = skill_name.lower().strip()
999
+ for root in visible_roots:
1000
+ for skill_md in discover_skill_md_files(Path(root)):
1001
+ skill = skill_from_markdown(skill_md, include_content=True)
1002
+ if not skill:
1003
+ continue
1004
+ candidates = {
1005
+ (skill.name or "").strip().lower(),
1006
+ skill.path.name.strip().lower(),
1007
+ }
1008
+ if target in candidates:
1009
+ return skill
1010
+
1011
+ return None
1012
+
1013
+
1014
+def _load_skill_from_runtime_path(
1015
+ skill_path: str,
1016
+ visible_roots: list[str],
1017
+) -> Skill | None:
1018
+ abs_path = files.fix_dev_path(skill_path)
1019
+ if not any(files.is_in_dir(abs_path, root) for root in visible_roots):
1020
+ return None
1021
+
1022
+ skill_md = Path(abs_path) / "SKILL.md"
1023
+ if not skill_md.is_file():
1024
+ return None
1025
+
1026
+ return skill_from_markdown(skill_md, include_content=True)
plugins/_skills/README.md
+8
-5
@@ -1,11 +1,12 @@
1
# Skills
2
3
-Skills is a built-in Agent Zero plugin that lets you pin skills into prompt extras for a chosen scope.
3
+Skills is a built-in Agent Zero plugin that manages active skills across scope defaults and the current chat.
4
5
## What It Does
6
7
-- activates selected skills for the current plugin scope
8
-- injects those skills into prompt extras on every turn
7
+- pins default skills for the current plugin scope
8
+- injects the effective active skills into prompt extras on every turn
9
+- extends the same config screen with a current-chat mode so users can activate or hide skills live per conversation
10
- supports global and project scoped configurations without agent-profile variants
11
- links directly to the built-in Skills list
12
- links directly to the active project's Skills section when a project is active
@@ -15,10 +16,12 @@ Skills is a built-in Agent Zero plugin that lets you pin skills into prompt extr
16
Agent Zero already supports loading skills dynamically with `skills_tool`, and already has great built-in skill management surfaces. What it did not have was a lightweight way to make a few skills feel "always on" for a specific scope without modifying the core prompt system.
17
18
Skills fills that gap as a bundled built-in plugin.
19
+The shared active-skill state and prompt-resolution logic live in `helpers/skills.py`, and this plugin focuses on configuration, UI, and prompt injection.
20
21
## Notes
22
21
-- keep the active list short because every selected skill is injected into prompt extras every turn
22
-- this plugin enforces the same extras cap as the core `skills_tool`: at most 5 active skills
23
+- keep the active list short because every active skill is injected into prompt extras every turn
24
+- the framework-wide cap is 20 active skills
25
- selected skills are stored in normalized `/a0/...` form so configs stay portable across development and Docker-style layouts
26
+- scope defaults can be hidden or supplemented per chat without creating a new conversation
27
- if a configured skill is not visible in the current agent scope, it is skipped quietly instead of breaking the prompt build
plugins/_skills/api/skills_catalog.py
+248
-9
@@ -1,24 +1,263 @@
1
from __future__ import annotations
2
3
-from helpers.api import ApiHandler, Request, Response
3
+from pathlib import Path
4
+from typing import Any
5
5
-from plugins._skills.helpers.runtime import (
6
- get_max_active_skills,
7
- list_catalog,
8
-)
6
+from agent import AgentContext
7
+from helpers import files, projects, skills
8
+from helpers.api import ApiHandler, Request, Response
9
+from helpers.persist_chat import save_tmp_chat
10
11
12
class SkillsCatalog(ApiHandler):
13
async def process(self, input: dict, request: Request) -> dict | Response:
14
action = str(input.get("action", "list") or "list").strip().lower()
15
+ context_id = str(input.get("context_id", "") or "").strip()
16
+ project_name = str(input.get("project_name", "") or "").strip()
17
15
- if action != "list":
18
+ try:
19
+ if action == "list":
20
+ return self._build_state(context_id=context_id, project_name=project_name)
21
+ if action == "activate":
22
+ return self._activate(input, context_id=context_id)
23
+ if action == "deactivate":
24
+ return self._deactivate(input, context_id=context_id)
25
+ if action == "clear":
26
+ return self._clear(context_id=context_id)
27
+ if action == "get_doc":
28
+ return self._get_doc(input, context_id=context_id, project_name=project_name)
29
return {"ok": False, "error": f"Unknown action: {action}"}
30
+ except Exception as e:
31
+ return {"ok": False, "error": str(e)}
32
18
- project_name = str(input.get("project_name", "") or "").strip()
33
+ def _activate(self, input: dict, *, context_id: str) -> dict[str, Any]:
34
+ context = self._require_context(context_id)
35
+ skill_entry = self._require_skill_entry(input)
36
+ skills.activate_chat_skill(context.get_agent(), skill_entry)
37
+ save_tmp_chat(context)
38
+ return self._build_state(context_id=context.id)
39
+
40
+ def _deactivate(self, input: dict, *, context_id: str) -> dict[str, Any]:
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
+ save_tmp_chat(context)
45
+ return self._build_state(context_id=context.id)
46
+
47
+ def _clear(self, *, context_id: str) -> dict[str, Any]:
48
+ context = self._require_context(context_id)
49
+ skills.clear_chat_skill_overrides(context.get_agent())
50
+ save_tmp_chat(context)
51
+ return self._build_state(context_id=context.id)
52
+
53
+ def _build_state(
54
+ self,
55
+ *,
56
+ context_id: str = "",
57
+ project_name: str = "",
58
+ ) -> dict[str, Any]:
59
+ context = AgentContext.get(context_id) if context_id else None
60
+ agent = context.get_agent() if context else None
61
+
62
+ if context and not project_name:
63
+ project_name = projects.get_context_project_name(context) or ""
64
+
65
+ catalog = skills.list_skill_catalog(project_name=project_name, agent=agent)
66
+ catalog_by_key = {self._entry_key(skill): skill for skill in catalog}
67
+ catalog_by_name = {
68
+ str(skill.get("name") or "").strip().lower(): skill for skill in catalog
69
+ }
70
+
71
+ scope_entries = skills.get_scope_active_skills(agent)
72
+ chat_entries = skills.get_chat_active_skills(context)
73
+ disabled_entries = skills.get_chat_disabled_skills(context)
74
+ active_entries = self._merge_entries(
75
+ skills.get_active_skills(agent),
76
+ self._get_loaded_skill_entries(agent),
77
+ )
78
+
79
+ scope_keys = {
80
+ self._entry_key(entry) for entry in scope_entries if self._entry_key(entry)
81
+ }
82
+ chat_keys = {
83
+ self._entry_key(entry) for entry in chat_entries if self._entry_key(entry)
84
+ }
85
+
86
+ return {
87
+ "ok": True,
88
+ "context_available": bool(context),
89
+ "context_id": context.id if context else "",
90
+ "project_name": project_name,
91
+ "skills": catalog,
92
+ "max_active_skills": skills.get_max_active_skills(),
93
+ "active_skills": [
94
+ self._serialize_entry(
95
+ entry,
96
+ catalog_by_key,
97
+ catalog_by_name,
98
+ state_source=(
99
+ "Pinned + chat"
100
+ if key in scope_keys and key in chat_keys
101
+ else "Pinned default"
102
+ if key in scope_keys
103
+ else "Chat"
104
+ ),
105
+ )
106
+ for entry in active_entries
107
+ if (key := self._entry_key(entry))
108
+ ],
109
+ "scope_skills": [
110
+ self._serialize_entry(
111
+ entry,
112
+ catalog_by_key,
113
+ catalog_by_name,
114
+ state_source="Pinned default",
115
+ )
116
+ for entry in scope_entries
117
+ ],
118
+ "chat_skills": [
119
+ self._serialize_entry(
120
+ entry,
121
+ catalog_by_key,
122
+ catalog_by_name,
123
+ state_source="Chat",
124
+ )
125
+ for entry in chat_entries
126
+ ],
127
+ "disabled_skills": [
128
+ self._serialize_entry(
129
+ entry,
130
+ catalog_by_key,
131
+ catalog_by_name,
132
+ state_source="Hidden in chat",
133
+ )
134
+ for entry in disabled_entries
135
+ ],
136
+ }
137
+
138
+ def _get_doc(
139
+ self,
140
+ input: dict,
141
+ *,
142
+ context_id: str = "",
143
+ project_name: str = "",
144
+ ) -> dict[str, Any]:
145
+ context = AgentContext.get(context_id) if context_id else None
146
+ agent = context.get_agent() if context else None
147
+
148
+ if context and not project_name:
149
+ project_name = projects.get_context_project_name(context) or ""
150
+
151
+ skill_entry = self._require_skill_entry(input)
152
+ requested_key = self._entry_key(skill_entry)
153
+ catalog = skills.list_skill_catalog(project_name=project_name, agent=agent)
154
+ skill = next(
155
+ (item for item in catalog if self._entry_key(item) == requested_key),
156
+ None,
157
+ )
158
+ if not skill and skill_entry.get("name"):
159
+ requested_name = str(skill_entry.get("name") or "").strip().lower()
160
+ skill = next(
161
+ (item for item in catalog if str(item.get("name") or "").strip().lower() == requested_name),
162
+ None,
163
+ )
164
+
165
+ if not skill:
166
+ raise ValueError("Skill not found in the current list")
167
+
168
+ skill_path = str(skill.get("path") or "").strip()
169
+ skill_md_path = Path(files.fix_dev_path(skill_path)) / "SKILL.md"
170
+ if not skill_md_path.is_file():
171
+ raise FileNotFoundError("SKILL.md not found")
172
173
return {
174
"ok": True,
22
- "skills": list_catalog(project_name=project_name),
23
- "max_active_skills": get_max_active_skills(),
175
+ "filename": f"{skill.get('name') or skill_md_path.parent.name} / SKILL.md",
176
+ "content": skill_md_path.read_text(encoding="utf-8", errors="replace"),
177
+ }
178
+
179
+ def _require_context(self, context_id: str) -> AgentContext:
180
+ if not context_id:
181
+ raise ValueError("context_id is required")
182
+
183
+ context = AgentContext.get(context_id)
184
+ if not context:
185
+ raise ValueError("Context not found")
186
+ return context
187
+
188
+ def _require_skill_entry(self, input: dict) -> dict[str, str]:
189
+ entries = skills.normalize_active_skills([input.get("skill")])
190
+ if not entries:
191
+ raise ValueError("skill is required")
192
+ return entries[0]
193
+
194
+ def _entry_key(self, entry: dict[str, Any]) -> str:
195
+ return str(entry.get("path") or entry.get("name") or "").strip().lower()
196
+
197
+ def _get_loaded_skill_entries(self, agent: Any | None) -> list[dict[str, str]]:
198
+ if not agent:
199
+ return []
200
+
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
+ ]
210
+
211
+ def _merge_entries(
212
+ self,
213
+ *entry_groups: list[dict[str, Any]],
214
+ ) -> list[dict[str, Any]]:
215
+ merged: list[dict[str, Any]] = []
216
+ seen: set[str] = set()
217
+
218
+ for entries in entry_groups:
219
+ for entry in entries:
220
+ key = self._entry_key(entry)
221
+ if not key or key in seen:
222
+ continue
223
+ seen.add(key)
224
+ merged.append(entry)
225
+
226
+ return merged
227
+
228
+ def _serialize_entry(
229
+ self,
230
+ entry: dict[str, Any],
231
+ catalog_by_key: dict[str, dict[str, Any]],
232
+ catalog_by_name: dict[str, dict[str, Any]],
233
+ *,
234
+ state_source: str,
235
+ ) -> dict[str, Any]:
236
+ key = self._entry_key(entry)
237
+ match = catalog_by_key.get(key)
238
+
239
+ if not match:
240
+ name_key = str(entry.get("name") or "").strip().lower()
241
+ if name_key:
242
+ match = catalog_by_name.get(name_key)
243
+
244
+ if match:
245
+ return {
246
+ **match,
247
+ "state_source": state_source,
248
+ "missing": False,
249
+ }
250
+
251
+ path = str(entry.get("path") or "").strip()
252
+ fallback_name = str(entry.get("name") or "").strip()
253
+ if not fallback_name and path:
254
+ fallback_name = Path(path).name or path
255
+
256
+ return {
257
+ "name": fallback_name or "(unnamed skill)",
258
+ "description": "",
259
+ "path": path,
260
+ "origin": "Unavailable",
261
+ "state_source": state_source,
262
+ "missing": True,
263
}
plugins/_skills/extensions/python/message_loop_prompts_after/_66_include_active_skills.py
+5
-18
@@ -1,36 +1,23 @@
1
from __future__ import annotations
2
3
+from helpers import skills
4
from agent import LoopData
4
-from helpers import plugins, projects
5
from helpers.extension import Extension
6
7
-from plugins._skills.helpers.runtime import PLUGIN_NAME, resolve_active_skills
8
-
7
8
class IncludeActiveSkills(Extension):
9
async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
10
if not self.agent:
11
return
12
15
- project_name = projects.get_context_project_name(self.agent.context) or ""
16
- config = (
17
- plugins.get_plugin_config(
18
- PLUGIN_NAME,
19
- agent=self.agent,
20
- project_name=project_name,
21
- agent_profile="",
22
- )
23
- or {}
24
- )
25
- active_skills = resolve_active_skills(self.agent, config.get("active_skills"))
26
- if not active_skills:
27
- return
13
+ extras = loop_data.extras_persistent
14
+ extras.pop("active_skills", None)
15
29
- content = "\n\n".join(item["content"] for item in active_skills if item.get("content")).strip()
16
+ content = skills.build_active_skills_prompt(self.agent)
17
if not content:
18
return
19
33
- loop_data.extras_persistent["active_skills"] = self.agent.read_prompt(
20
+ extras["active_skills"] = self.agent.read_prompt(
21
"agent.system.active_skills.md",
22
skills=content,
23
)
plugins/_skills/extensions/webui/initFw_end/skills-menu-injector.js
+3
-3
@@ -1,6 +1,6 @@
1
import { store as pluginSettingsStore } from "/components/plugins/plugin-settings-store.js";
2
-import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
2
import { store as chatInputStore } from "/components/chat/input/input-store.js";
3
+import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
4
5
const MENU_SELECTOR = ".chat-bottom-actions-menu";
6
const BUTTON_ID = "skills-chat-more-item";
@@ -16,9 +16,9 @@ function buildButton() {
16
`;
17
18
button.addEventListener("click", async () => {
19
- const projectName = chatsStore.selectedContext?.project?.name || "";
19
chatInputStore.closeChatMoreMenu();
21
- await pluginSettingsStore.openConfig("_skills", projectName, "");
20
+ const projectName = chatsStore.selectedContext?.project?.name || "";
21
+ await pluginSettingsStore.openConfig("_skills", projectName, "", { focus: "chat" });
22
});
23
24
return button;
plugins/_skills/helpers/__init__.py
+1
-1
@@ -1 +1 @@
1
-# Helpers for the Skill Switchboard plugin.
1
+# Plugin-local helpers for the Skills UI plugin.
plugins/_skills/helpers/runtime.py
deleted
-255
@@ -1,255 +0,0 @@
1
-from __future__ import annotations
2
-
3
-from pathlib import Path
4
-from typing import Any, TypedDict
5
-
6
-from helpers import files, plugins as plugin_helpers, projects, skills
7
-
8
-
9
-PLUGIN_NAME = "_skills"
10
-DEFAULT_MAX_ACTIVE_SKILLS = 5
11
-
12
-
13
-class ActiveSkillEntry(TypedDict, total=False):
14
- name: str
15
- path: str
16
-
17
-
18
-class CatalogSkill(TypedDict):
19
- name: str
20
- description: str
21
- path: str
22
- origin: str
23
-
24
-
25
-def get_max_active_skills() -> int:
26
- try:
27
- from tools.skills_tool import max_loaded_skills
28
-
29
- value = int(max_loaded_skills())
30
- return value if value > 0 else DEFAULT_MAX_ACTIVE_SKILLS
31
- except Exception:
32
- return DEFAULT_MAX_ACTIVE_SKILLS
33
-
34
-
35
-def coerce_config(config: dict[str, Any] | None) -> dict[str, Any]:
36
- normalized = dict(config or {})
37
- normalized["active_skills"] = normalize_active_skills(normalized.get("active_skills"))
38
- return normalized
39
-
40
-
41
-def normalize_active_skills(raw: Any) -> list[ActiveSkillEntry]:
42
- if not isinstance(raw, list):
43
- return []
44
-
45
- limit = get_max_active_skills()
46
- normalized: list[ActiveSkillEntry] = []
47
- seen: set[str] = set()
48
-
49
- for item in raw:
50
- entry = _normalize_active_skill_entry(item)
51
- if not entry:
52
- continue
53
-
54
- key = _entry_key(entry)
55
- if not key or key in seen:
56
- continue
57
-
58
- seen.add(key)
59
- normalized.append(entry)
60
- if len(normalized) >= limit:
61
- break
62
-
63
- return normalized
64
-
65
-
66
-def list_catalog(project_name: str = "") -> list[CatalogSkill]:
67
- catalog: list[CatalogSkill] = []
68
- seen_paths: set[str] = set()
69
-
70
- for root in _get_catalog_roots(project_name=project_name):
71
- root_path = Path(root)
72
- for skill_md in skills.discover_skill_md_files(root_path):
73
- skill = skills.skill_from_markdown(skill_md, include_content=False)
74
- if not skill:
75
- continue
76
-
77
- runtime_path = files.normalize_a0_path(str(skill.path))
78
- if runtime_path in seen_paths:
79
- continue
80
-
81
- seen_paths.add(runtime_path)
82
- catalog.append(
83
- {
84
- "name": skill.name or skill.path.name,
85
- "description": skill.description or "",
86
- "path": runtime_path,
87
- "origin": classify_origin(runtime_path, project_name=project_name),
88
- }
89
- )
90
-
91
- catalog.sort(key=lambda item: (item["name"].lower(), item["path"]))
92
- return catalog
93
-
94
-
95
-def resolve_active_skills(agent: Any, raw_entries: Any) -> list[dict[str, str]]:
96
- visible_roots = [files.fix_dev_path(root) for root in skills.get_skill_roots(agent)]
97
- resolved: list[dict[str, str]] = []
98
- seen_paths: set[str] = set()
99
-
100
- for entry in normalize_active_skills(raw_entries):
101
- skill = _resolve_skill_entry(entry, visible_roots)
102
- if not skill:
103
- continue
104
-
105
- runtime_path = files.normalize_a0_path(str(skill.path))
106
- if runtime_path in seen_paths:
107
- continue
108
-
109
- seen_paths.add(runtime_path)
110
- resolved.append(
111
- {
112
- "name": skill.name or skill.path.name,
113
- "path": runtime_path,
114
- "content": format_skill_for_prompt(skill),
115
- }
116
- )
117
-
118
- return resolved
119
-
120
-
121
-def format_skill_for_prompt(skill: skills.Skill) -> str:
122
- lines = [
123
- f"Skill: {skill.name or skill.path.name}",
124
- f"Path: {files.normalize_a0_path(str(skill.path))}",
125
- ]
126
-
127
- if skill.description:
128
- lines.extend(["", "Description:", skill.description.strip()])
129
-
130
- lines.extend(["", "Instructions:", (skill.content or "").strip() or "(empty)"])
131
- return "\n".join(lines)
132
-
133
-
134
-def classify_origin(skill_path: str, project_name: str = "") -> str:
135
- abs_path = files.fix_dev_path(skill_path)
136
-
137
- if project_name:
138
- project_root = projects.get_project_meta(project_name, "skills")
139
- if files.exists(project_root) and files.is_in_dir(abs_path, project_root):
140
- return "Project"
141
-
142
- user_root = files.get_abs_path("usr", "skills")
143
- if files.exists(user_root) and files.is_in_dir(abs_path, user_root):
144
- return "User"
145
-
146
- normalized_path = files.normalize_a0_path(abs_path)
147
- if "/usr/plugins/" in normalized_path:
148
- return "Community plugin"
149
- if "/plugins/" in normalized_path:
150
- return "Built-in plugin"
151
- return "Built-in"
152
-
153
-
154
-def _entry_key(entry: ActiveSkillEntry) -> str:
155
- return str(entry.get("path") or entry.get("name") or "").strip().lower()
156
-
157
-
158
-def _normalize_active_skill_entry(item: Any) -> ActiveSkillEntry | None:
159
- if isinstance(item, str):
160
- stripped = item.strip()
161
- if not stripped:
162
- return None
163
- if "/" in stripped:
164
- return {"path": _normalize_skill_path(stripped)}
165
- return {"name": stripped}
166
-
167
- if not isinstance(item, dict):
168
- return None
169
-
170
- name = str(item.get("name") or "").strip()
171
- path = str(item.get("path") or "").strip()
172
-
173
- if path:
174
- path = _normalize_skill_path(path)
175
- if not (path or name):
176
- return None
177
-
178
- entry: ActiveSkillEntry = {}
179
- if name:
180
- entry["name"] = name
181
- if path:
182
- entry["path"] = path
183
- return entry
184
-
185
-
186
-def _normalize_skill_path(path: str) -> str:
187
- fixed = path.strip().replace("\\", "/")
188
- if fixed.startswith("/a0/"):
189
- return fixed.rstrip("/")
190
- if fixed.startswith("/"):
191
- return files.normalize_a0_path(fixed).rstrip("/")
192
- return files.normalize_a0_path(files.get_abs_path(fixed)).rstrip("/")
193
-
194
-
195
-def _get_catalog_roots(project_name: str = "") -> list[str]:
196
- roots: list[str] = []
197
- seen: set[str] = set()
198
-
199
- def add(path: str) -> None:
200
- if not path:
201
- return
202
- fixed = files.fix_dev_path(path)
203
- if not files.exists(fixed) or fixed in seen:
204
- return
205
- seen.add(fixed)
206
- roots.append(fixed)
207
-
208
- if project_name:
209
- add(projects.get_project_meta(project_name, "skills"))
210
-
211
- add(files.get_abs_path("usr", "skills"))
212
- for path in plugin_helpers.get_enabled_plugin_paths(None, "skills"):
213
- add(path)
214
- add(files.get_abs_path("skills"))
215
-
216
- return roots
217
-
218
-
219
-def _resolve_skill_entry(entry: ActiveSkillEntry, visible_roots: list[str]) -> skills.Skill | None:
220
- skill_path = str(entry.get("path") or "").strip()
221
- if skill_path:
222
- skill = _load_skill_from_path(skill_path, visible_roots)
223
- if skill:
224
- return skill
225
-
226
- skill_name = str(entry.get("name") or "").strip()
227
- if not skill_name:
228
- return None
229
-
230
- target = skill_name.lower().strip()
231
- for root in visible_roots:
232
- for skill_md in skills.discover_skill_md_files(Path(root)):
233
- skill = skills.skill_from_markdown(skill_md, include_content=True)
234
- if not skill:
235
- continue
236
- candidates = {
237
- (skill.name or "").strip().lower(),
238
- skill.path.name.strip().lower(),
239
- }
240
- if target in candidates:
241
- return skill
242
-
243
- return None
244
-
245
-
246
-def _load_skill_from_path(skill_path: str, visible_roots: list[str]) -> skills.Skill | None:
247
- abs_path = files.fix_dev_path(skill_path)
248
- if not any(files.is_in_dir(abs_path, root) for root in visible_roots):
249
- return None
250
-
251
- skill_md = Path(abs_path) / "SKILL.md"
252
- if not skill_md.is_file():
253
- return None
254
-
255
- return skills.skill_from_markdown(skill_md, include_content=True)
plugins/_skills/hooks.py
+3
-3
@@ -1,11 +1,11 @@
1
from __future__ import annotations
2
3
-from plugins._skills.helpers.runtime import coerce_config
3
+from helpers.skills import normalize_skills_config
4
5
6
def get_plugin_config(default=None, **kwargs):
7
- return coerce_config(default)
7
+ return normalize_skills_config(default)
8
9
10
def save_plugin_config(settings=None, **kwargs):
11
- return coerce_config(settings)
11
+ return normalize_skills_config(settings)
plugins/_skills/webui/config-store.js
+124
-80
@@ -1,14 +1,13 @@
1
import * as API from "/js/api.js";
2
-import { store as settingsStore } from "/components/settings/settings-store.js";
2
+import { store as markdownModalStore } from "/components/modals/markdown/markdown-store.js";
3
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
4
-import { store as projectsStore } from "/components/projects/projects-store.js";
4
import {
5
toastFrontendError,
6
toastFrontendInfo,
7
} from "/components/notifications/notification-store.js";
8
9
const CATALOG_API = "/plugins/_skills/skills_catalog";
11
-const MAX_ACTIVE_SKILLS_FALLBACK = 5;
10
+const MAX_ACTIVE_SKILLS_FALLBACK = 20;
11
12
function normalizeEntry(entry) {
13
if (!entry) return null;
@@ -52,14 +51,28 @@ function ensureConfig(config) {
51
config.active_skills = normalized;
52
}
53
54
+function compactEntries(entries) {
55
+ return entries
56
+ .map((entry) => normalizeEntry(entry))
57
+ .filter(Boolean)
58
+ .map((entry) => ({
59
+ ...(entry.name ? { name: entry.name } : {}),
60
+ ...(entry.path ? { path: entry.path } : {}),
61
+ }));
62
+}
63
+
64
window.createSkillsConfigModel = (context, config) => ({
65
loadingCatalog: false,
66
+ mutatingChat: false,
67
catalog: [],
68
search: "",
69
maxActiveSkills: MAX_ACTIVE_SKILLS_FALLBACK,
70
+ selectedSkills: [],
71
+ chatContextAvailable: false,
72
73
initDefaults() {
74
ensureConfig(config);
75
+ this.selectedSkills = [...this.activeEntries];
76
},
77
78
get activeEntries() {
@@ -68,28 +81,13 @@ window.createSkillsConfigModel = (context, config) => ({
81
},
82
83
get selectedCount() {
71
- return this.activeEntries.length;
84
+ return this.selectedSkills.length;
85
},
86
87
get selectedCountLabel() {
88
return `${this.selectedCount} / ${this.maxActiveSkills}`;
89
},
90
78
- get limitDescription() {
79
- return `Max in extras: ${this.maxActiveSkills}`;
80
- },
81
-
82
- get activeProject() {
83
- return chatsStore.selectedContext?.project || null;
84
- },
85
-
86
- get scopeSummary() {
87
- if (context.projectName) {
88
- return `Project: ${context.projectLabel(context.projectName)}`;
89
- }
90
- return "Project: Global";
91
- },
92
-
91
get catalogMap() {
92
const byKey = new Map();
93
for (const skill of this.catalog) {
@@ -124,11 +122,11 @@ window.createSkillsConfigModel = (context, config) => ({
122
},
123
124
isSelected(skill) {
127
- return this.activeEntries.some((entry) => entryKey(entry) === entryKey(skill));
125
+ return this.selectedSkills.some((entry) => entryKey(entry) === entryKey(skill));
126
},
127
128
isCheckboxDisabled(skill) {
131
- return !this.isSelected(skill) && this.selectedCount >= this.maxActiveSkills;
129
+ return this.mutatingChat || (!this.isSelected(skill) && this.selectedCount >= this.maxActiveSkills);
130
},
131
132
isEntryMissing(entry) {
@@ -147,11 +145,9 @@ window.createSkillsConfigModel = (context, config) => ({
145
146
secondaryLabelForEntry(entry) {
147
const skill = this._resolveEntry(entry);
150
- if (skill) {
151
- return `${skill.origin} | ${skill.path}`;
152
- }
153
- if (entry?.path) return `Not visible in the current catalog | ${entry.path}`;
154
- return "Not visible in the current catalog";
148
+ if (skill) return `${skill.origin} | ${skill.path}`;
149
+ if (entry?.path) return `Not visible in the current list | ${entry.path}`;
150
+ return "Not visible in the current list";
151
},
152
153
_resolveEntry(entry) {
@@ -163,15 +159,31 @@ window.createSkillsConfigModel = (context, config) => ({
159
return name ? this.catalogMap.get(name) || null : null;
160
},
161
166
- toggleSkill(skill, selected) {
167
- ensureConfig(config);
162
+ _setSelectedSkills(entries) {
163
+ const normalized = [];
164
+ const seen = new Set();
165
+
166
+ for (const entry of entries) {
167
+ const item = normalizeEntry(entry);
168
+ const key = entryKey(item);
169
+ if (!item || !key || seen.has(key)) continue;
170
+ seen.add(key);
171
+ normalized.push(item);
172
+ if (normalized.length >= this.maxActiveSkills) break;
173
+ }
174
+
175
+ this.selectedSkills = normalized;
176
+ config.active_skills = compactEntries(normalized);
177
+ },
178
+
179
+ async toggleSkill(skill, selected) {
180
const key = entryKey(skill);
169
- const nextEntries = this.activeEntries.filter((entry) => entryKey(entry) !== key);
181
+ const nextEntries = this.selectedSkills.filter((entry) => entryKey(entry) !== key);
182
183
if (selected) {
172
- if (this.selectedCount >= this.maxActiveSkills) {
173
- void toastFrontendInfo(
174
- `You can activate at most ${this.maxActiveSkills} skills in extras.`,
184
+ if (this.selectedCount >= this.maxActiveSkills && !this.isSelected(skill)) {
185
+ await toastFrontendInfo(
186
+ `You can activate at most ${this.maxActiveSkills} skills.`,
187
"Skills"
188
);
189
return;
@@ -183,18 +195,32 @@ window.createSkillsConfigModel = (context, config) => ({
195
});
196
}
197
186
- config.active_skills = nextEntries;
198
+ this._setSelectedSkills(nextEntries);
199
+
200
+ if (this.chatContextAvailable) {
201
+ await this.submitChatAction(selected ? "activate" : "deactivate", skill);
202
+ }
203
},
204
189
- removeEntry(entry) {
190
- ensureConfig(config);
191
- const key = entryKey(entry);
192
- config.active_skills = this.activeEntries.filter((item) => entryKey(item) !== key);
205
+ async removeEntry(entry) {
206
+ await this.toggleSkill(entry, false);
207
},
208
195
- clearSelections() {
196
- ensureConfig(config);
197
- config.active_skills = [];
209
+ async clearSelections() {
210
+ const previous = [...this.selectedSkills];
211
+ this._setSelectedSkills([]);
212
+ if (this.chatContextAvailable) {
213
+ for (const entry of previous) {
214
+ await this.submitChatAction("deactivate", entry);
215
+ }
216
+ }
217
+ },
218
+
219
+ applyCatalogState(response) {
220
+ this.chatContextAvailable = !!response?.context_available;
221
+ const activeFromChat = Array.isArray(response?.active_skills) ? response.active_skills : null;
222
+ const activeFromConfig = this.activeEntries;
223
+ this._setSelectedSkills(activeFromChat || activeFromConfig);
224
},
225
226
async loadCatalog() {
@@ -203,67 +229,85 @@ window.createSkillsConfigModel = (context, config) => ({
229
const response = await API.callJsonApi(CATALOG_API, {
230
action: "list",
231
project_name: context.projectName || "",
232
+ context_id: chatsStore.selectedContext?.id || "",
233
});
234
235
if (!response?.ok) {
209
- throw new Error(response?.error || "Failed to load skills catalog");
236
+ throw new Error(response?.error || "Failed to load skills");
237
}
238
239
this.catalog = Array.isArray(response.skills) ? response.skills : [];
240
this.maxActiveSkills = Number(response.max_active_skills) || MAX_ACTIVE_SKILLS_FALLBACK;
241
+ this.applyCatalogState(response);
242
} catch (error) {
243
this.catalog = [];
244
this.maxActiveSkills = MAX_ACTIVE_SKILLS_FALLBACK;
217
- await toastFrontendError(error?.message || "Failed to load skills catalog", "Skills");
245
+ this.chatContextAvailable = false;
246
+ this._setSelectedSkills(this.activeEntries);
247
+ await toastFrontendError(error?.message || "Failed to load skills", "Skills");
248
} finally {
249
this.loadingCatalog = false;
250
}
251
},
252
223
- async navigateAway(callback) {
224
- if (context.hasUnsavedChanges && !context.confirmDiscardUnsavedChanges()) {
225
- return;
226
- }
227
-
228
- await window.closeModal?.();
229
- await callback();
230
- },
253
+ async submitChatAction(action, skill = null) {
254
+ this.mutatingChat = true;
255
+ try {
256
+ const response = await API.callJsonApi(CATALOG_API, {
257
+ action,
258
+ context_id: chatsStore.selectedContext?.id || "",
259
+ project_name: context.projectName || "",
260
+ ...(skill
261
+ ? {
262
+ skill: {
263
+ name: String(skill.name || "").trim(),
264
+ path: String(skill.path || "").trim(),
265
+ },
266
+ }
267
+ : {}),
268
+ });
269
232
- async openSettingsSkills() {
233
- await this.navigateAway(async () => {
234
- await settingsStore.open("skills");
235
- });
236
- },
270
+ if (!response?.ok) {
271
+ throw new Error(response?.error || "Failed to update skills");
272
+ }
273
238
- async openActiveProjectSkills() {
239
- const projectName = this.activeProject?.name;
240
- if (!projectName) {
241
- await toastFrontendInfo("No active project is selected in the current chat.", "Skills");
242
- return;
274
+ this.catalog = Array.isArray(response.skills) ? response.skills : this.catalog;
275
+ this.maxActiveSkills = Number(response.max_active_skills) || this.maxActiveSkills;
276
+ this.chatContextAvailable = !!response.context_available;
277
+ return true;
278
+ } catch (error) {
279
+ await toastFrontendError(error?.message || "Failed to update skills", "Skills");
280
+ return false;
281
+ } finally {
282
+ this.mutatingChat = false;
283
}
244
-
245
- await this.navigateAway(async () => {
246
- await projectsStore.openEditModal(projectName);
247
- this.scrollProjectSkillsSection();
248
- });
284
},
285
251
- scrollProjectSkillsSection(attempt = 0) {
252
- const headers = Array.from(
253
- document.querySelectorAll(".project-detail-header .projects-project-card-title")
254
- );
255
- const target = headers.find(
256
- (header) => header.textContent?.trim().toLowerCase() === "skills"
257
- );
258
- const section = target?.closest(".project-detail");
259
-
260
- if (section) {
261
- section.scrollIntoView({ behavior: "smooth", block: "start" });
262
- return;
263
- }
286
+ async openSkill(skill) {
287
+ try {
288
+ const response = await API.callJsonApi(CATALOG_API, {
289
+ action: "get_doc",
290
+ context_id: chatsStore.selectedContext?.id || "",
291
+ project_name: context.projectName || "",
292
+ skill: {
293
+ name: String(skill?.name || "").trim(),
294
+ path: String(skill?.path || "").trim(),
295
+ },
296
+ });
297
265
- if (attempt < 12) {
266
- window.setTimeout(() => this.scrollProjectSkillsSection(attempt + 1), 120);
298
+ if (!response?.ok) {
299
+ throw new Error(response?.error || "Failed to open skill");
300
+ }
301
+ if (!markdownModalStore?.open) {
302
+ throw new Error("Markdown viewer is unavailable");
303
+ }
304
+
305
+ markdownModalStore.open(response.filename || "SKILL.md", response.content || "", {
306
+ viewer: "ace",
307
+ });
308
+ window.openModal?.("components/modals/markdown/markdown-modal.html");
309
+ } catch (error) {
310
+ await toastFrontendError(error?.message || "Failed to open skill", "Skills");
311
}
312
},
313
});
plugins/_skills/webui/config.html
+144
-184
@@ -12,117 +12,97 @@
12
$watch('context.projectName', async () => { await loadCatalog(); });
13
"
14
>
15
- <div class="ssb-layout">
15
+ <div class="skills-layout">
16
<div class="section-title">Skills</div>
17
- <div class="section-description">
18
- Pin skills for the selected project scope. Every active skill is injected into prompt extras on each turn, so keep this list lean and intentional.
19
- </div>
20
-
21
- <div class="ssb-link-row">
22
- <button type="button" class="button" @click="openSettingsSkills()">
23
- <span class="icon material-symbols-outlined">menu_book</span>
24
- Open Skills List
25
- </button>
26
- <button
27
- type="button"
28
- class="button"
29
- x-show="activeProject"
30
- @click="openActiveProjectSkills()"
31
- >
32
- <span class="icon material-symbols-outlined">snippet_folder</span>
33
- Open Active Project Skills
34
- </button>
35
- </div>
36
-
37
- <div class="ssb-summary-grid">
38
- <div class="ssb-summary-card">
39
- <span class="ssb-summary-label">Selected scope</span>
40
- <strong x-text="scopeSummary"></strong>
41
- </div>
42
- <div class="ssb-summary-card">
43
- <span class="ssb-summary-label">Active skills</span>
44
- <strong x-text="selectedCountLabel"></strong>
45
- </div>
46
- <div class="ssb-summary-card" x-show="activeProject">
47
- <span class="ssb-summary-label">Current chat project</span>
48
- <strong x-text="activeProject?.title || activeProject?.name || ''"></strong>
49
- </div>
50
- </div>
17
52
- <div class="ssb-controls">
53
- <label class="ssb-search">
18
+ <div class="skills-toolbar">
19
+ <label class="skills-search">
20
<span class="material-symbols-outlined">search</span>
21
<input
22
type="text"
23
x-model.trim="search"
58
- placeholder="Filter skills by name, description, path, or origin"
24
+ placeholder="Search skills"
25
>
26
</label>
27
62
- <div class="ssb-control-actions">
63
- <button type="button" class="button" @click="loadCatalog()" :disabled="loadingCatalog">
28
+ <div class="skills-actions">
29
+ <button type="button" class="button" @click="loadCatalog()" :disabled="loadingCatalog || mutatingChat">
30
<span class="icon material-symbols-outlined">refresh</span>
31
Refresh
32
</button>
67
- <button type="button" class="button cancel" @click="clearSelections()" :disabled="selectedCount === 0">
68
- <span class="icon material-symbols-outlined">ink_eraser</span>
69
- Clear
70
- </button>
33
</div>
34
</div>
35
74
- <div class="ssb-panel" x-show="activeEntries.length > 0">
75
- <div class="ssb-panel-title">Selected for this scope</div>
76
- <div class="ssb-selected-list">
77
- <template x-for="entry in activeEntries" :key="entryKey(entry)">
78
- <div class="ssb-selected-card" :class="{ 'is-missing': isEntryMissing(entry) }">
79
- <div class="ssb-selected-copy">
80
- <div class="ssb-selected-title" x-text="labelForEntry(entry)"></div>
81
- <div class="ssb-selected-meta" x-text="secondaryLabelForEntry(entry)"></div>
36
+ <div class="skills-panel" x-show="selectedSkills.length > 0">
37
+ <div class="skills-panel-title">Active skills</div>
38
+ <div class="skills-selected-list">
39
+ <template x-for="entry in selectedSkills" :key="entryKey(entry)">
40
+ <div class="skills-selected-card" :class="{ 'is-missing': isEntryMissing(entry) }">
41
+ <div class="skills-selected-copy">
42
+ <div class="skills-selected-title" x-text="labelForEntry(entry)"></div>
43
+ <div class="skills-selected-meta" x-text="secondaryLabelForEntry(entry)"></div>
44
+ </div>
45
+ <div class="skills-card-actions">
46
+ <button
47
+ type="button"
48
+ class="button icon-button"
49
+ title="Open skill"
50
+ aria-label="Open skill"
51
+ @click="openSkill(entry)"
52
+ >
53
+ <span class="icon material-symbols-outlined">article</span>
54
+ </button>
55
+ <button
56
+ type="button"
57
+ class="button cancel icon-button"
58
+ title="Remove"
59
+ aria-label="Remove skill"
60
+ @click="$confirmClick($event, () => removeEntry(entry))"
61
+ :disabled="mutatingChat"
62
+ >
63
+ <span class="icon material-symbols-outlined">close</span>
64
+ </button>
65
</div>
83
- <button type="button" class="button cancel icon-button" title="Remove" @click="removeEntry(entry)">
84
- <span class="icon material-symbols-outlined">close</span>
85
- </button>
66
</div>
67
</template>
68
</div>
69
</div>
70
91
- <div class="ssb-panel">
92
- <div class="ssb-panel-title">Available skills</div>
93
- <div class="ssb-panel-subtitle">
94
- The catalog below resolves for the currently selected plugin scope. Changing the project selector above refreshes the available set.
95
- </div>
96
- <div class="ssb-panel-subtitle" x-text="limitDescription"></div>
71
+ <div class="skills-panel">
72
+ <div class="skills-panel-title">Available skills</div>
73
+ <div class="skills-panel-subtitle">Check a skill to add it. Uncheck it to remove it.</div>
74
98
- <div class="ssb-loading" x-show="loadingCatalog">
75
+ <div class="skills-loading" x-show="loadingCatalog">
76
<span class="material-symbols-outlined spinning">progress_activity</span>
100
- <span>Loading skills catalog...</span>
77
+ <span>Loading skills...</span>
78
</div>
79
80
<template x-if="!loadingCatalog && filteredCatalog.length === 0">
104
- <div class="ssb-empty">No skills matched the current scope or filter.</div>
81
+ <div class="skills-empty">No skills matched your search.</div>
82
</template>
83
107
- <div class="ssb-skill-list">
84
+ <div class="skills-list">
85
<template x-for="skill in filteredCatalog" :key="skill.path">
109
- <label class="ssb-skill-card">
110
- <div class="ssb-skill-main">
111
- <input
112
- type="checkbox"
113
- :checked="isSelected(skill)"
114
- :disabled="isCheckboxDisabled(skill)"
115
- @change="toggleSkill(skill, $event.target.checked)"
116
- >
117
- <div class="ssb-skill-copy">
118
- <div class="ssb-skill-header">
119
- <div class="ssb-skill-title" x-text="skill.name || '(unnamed skill)'"></div>
120
- <span class="ssb-origin-pill" x-text="skill.origin"></span>
121
- </div>
122
- <div class="ssb-skill-description" x-text="skill.description || 'No description provided.'"></div>
123
- <code class="ssb-skill-path" x-text="skill.path"></code>
124
- </div>
86
+ <label class="skills-card" :class="{ 'is-selected': isSelected(skill) }">
87
+ <input
88
+ type="checkbox"
89
+ :checked="isSelected(skill)"
90
+ :disabled="isCheckboxDisabled(skill)"
91
+ @change="toggleSkill(skill, $event.target.checked)"
92
+ >
93
+ <div class="skills-card-copy">
94
+ <div class="skills-card-title" x-text="skill.name || '(unnamed skill)'"></div>
95
+ <div class="skills-card-description" x-text="skill.description || 'No description provided.'"></div>
96
</div>
97
+ <button
98
+ type="button"
99
+ class="button icon-button"
100
+ title="Open skill"
101
+ aria-label="Open skill"
102
+ @click.prevent.stop="openSkill(skill)"
103
+ >
104
+ <span class="icon material-symbols-outlined">article</span>
105
+ </button>
106
</label>
107
</template>
108
</div>
@@ -131,101 +111,82 @@
111
</div>
112
113
<style>
134
- .ssb-layout {
114
+ .skills-layout {
115
display: flex;
116
flex-direction: column;
117
gap: 1rem;
118
}
119
140
- .ssb-link-row {
141
- display: flex;
142
- gap: 0.75rem;
143
- flex-wrap: wrap;
144
- }
145
-
146
- .ssb-summary-grid {
147
- display: grid;
148
- grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
149
- gap: 0.75rem;
150
- }
151
-
152
- .ssb-summary-card,
153
- .ssb-panel {
154
- border: 1px solid var(--color-border);
155
- border-radius: 0.75rem;
156
- background: var(--color-bg-secondary);
157
- padding: 0.9rem 1rem;
158
- }
159
-
160
- .ssb-summary-card {
161
- display: flex;
162
- flex-direction: column;
163
- gap: 0.25rem;
164
- }
165
-
166
- .ssb-summary-label,
167
- .ssb-panel-subtitle,
168
- .ssb-selected-meta,
169
- .ssb-skill-description,
170
- .ssb-skill-path {
171
- color: var(--color-text-secondary);
172
- font-size: var(--font-size-small);
173
- }
174
-
175
- .ssb-controls {
120
+ .skills-toolbar {
121
display: flex;
122
+ align-items: center;
123
gap: 0.75rem;
124
flex-wrap: wrap;
179
- align-items: center;
125
}
126
182
- .ssb-search {
127
+ .skills-search {
128
flex: 1 1 18rem;
129
min-width: 14rem;
130
display: flex;
131
align-items: center;
132
gap: 0.65rem;
133
border: 1px solid var(--color-border);
189
- border-radius: 999px;
134
+ border-radius: 0.5rem;
135
background: var(--color-bg-primary);
191
- padding: 0.55rem 0.85rem;
136
+ padding: var(--spacing-xs) var(--spacing-sm);
137
}
138
194
- .ssb-search input {
139
+ .skills-search input {
140
width: 100%;
141
border: none;
142
background: transparent;
143
outline: none;
144
}
145
201
- .ssb-control-actions {
146
+ .skills-actions {
147
display: flex;
148
+ align-items: center;
149
gap: 0.5rem;
150
flex-wrap: wrap;
151
margin-left: auto;
152
}
153
208
- .ssb-panel-title {
154
+ .skills-count {
155
+ color: var(--color-text-secondary);
156
+ font-size: var(--font-size-small);
157
+ font-weight: 600;
158
+ white-space: nowrap;
159
+ }
160
+
161
+ .skills-help,
162
+ .skills-panel-subtitle,
163
+ .skills-selected-meta,
164
+ .skills-card-description {
165
+ color: var(--color-text-secondary);
166
+ font-size: var(--font-size-small);
167
+ }
168
+
169
+ .skills-panel-title {
170
font-weight: 700;
171
margin-bottom: 0.3rem;
172
}
173
213
- .ssb-selected-list,
214
- .ssb-skill-list {
174
+ .skills-selected-list,
175
+ .skills-list {
176
display: flex;
177
flex-direction: column;
217
- gap: 0.75rem;
178
+ gap: 0.65rem;
179
margin-top: 0.75rem;
180
}
181
221
- .ssb-selected-card,
222
- .ssb-skill-card {
182
+ .skills-selected-card,
183
+ .skills-card {
184
border: 1px solid var(--color-border);
185
border-radius: 0.75rem;
186
background: var(--color-bg-primary);
187
}
188
228
- .ssb-selected-card {
189
+ .skills-selected-card {
190
display: flex;
191
align-items: center;
192
justify-content: space-between;
@@ -233,80 +194,90 @@
194
padding: 0.8rem 0.9rem;
195
}
196
236
- .ssb-selected-card.is-missing {
197
+ .skills-selected-card.is-missing {
198
border-style: dashed;
199
}
200
240
- .ssb-selected-copy {
201
+ .skills-selected-copy {
202
+ flex: 1 1 auto;
203
min-width: 0;
204
}
205
244
- .ssb-selected-title {
245
- font-weight: 600;
206
+ .skills-selected-title,
207
+ .skills-card-title {
208
+ font-weight: 650;
209
word-break: break-word;
210
}
211
249
- .ssb-skill-card {
250
- padding: 0.9rem;
251
- cursor: pointer;
212
+ .skills-selected-meta {
213
+ margin-top: 0.25rem;
214
+ word-break: break-all;
215
}
216
254
- .ssb-skill-main {
217
+ .skills-card {
218
display: flex;
219
align-items: flex-start;
257
- gap: 0.9rem;
220
+ gap: 0.85rem;
221
+ padding: 0.85rem;
222
+ cursor: pointer;
223
+ transition: border-color 0.15s ease, background-color 0.15s ease;
224
}
225
260
- .ssb-skill-copy {
261
- min-width: 0;
262
- flex: 1 1 auto;
263
- display: flex;
264
- flex-direction: column;
265
- gap: 0.35rem;
226
+ .skills-card.is-selected {
227
+ border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-border));
228
+ background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-primary));
229
}
230
268
- .ssb-skill-header {
269
- display: flex;
270
- gap: 0.75rem;
271
- justify-content: space-between;
272
- align-items: flex-start;
273
- flex-wrap: wrap;
231
+ .skills-card input {
232
+ margin-top: 0.15rem;
233
}
234
276
- .ssb-skill-title {
277
- font-weight: 600;
278
- font-size: 1rem;
235
+ .skills-card-copy {
236
+ flex: 1 1 auto;
237
+ min-width: 0;
238
}
239
281
- .ssb-origin-pill {
282
- display: inline-flex;
240
+ .skills-card-actions {
241
+ display: flex;
242
align-items: center;
284
- border-radius: 999px;
285
- padding: 0.15rem 0.55rem;
286
- background: var(--color-bg-tertiary);
287
- color: var(--color-text-secondary);
288
- font-size: 0.78rem;
289
- white-space: nowrap;
243
+ gap: 0.5rem;
244
+ flex: 0 0 auto;
245
+ margin-left: auto;
246
}
247
292
- .ssb-skill-path {
293
- white-space: pre-wrap;
294
- word-break: break-word;
295
- overflow-wrap: anywhere;
248
+ .skills-card-description {
249
+ margin-top: 0.3rem;
250
+ line-height: 1.45;
251
}
252
298
- .ssb-empty,
299
- .ssb-loading {
253
+ .skills-loading {
254
display: flex;
255
align-items: center;
302
- justify-content: center;
303
- gap: 0.6rem;
256
+ gap: 0.5rem;
257
margin-top: 0.75rem;
305
- padding: 1rem;
258
+ color: var(--color-text-secondary);
259
+ }
260
+
261
+ .skills-empty {
262
border: 1px dashed var(--color-border);
263
border-radius: 0.75rem;
264
color: var(--color-text-secondary);
309
- text-align: center;
265
+ margin-top: 0.75rem;
266
+ padding: 1rem;
267
+ }
268
+
269
+ .button, .button.cancel {
270
+ padding: var(--spacing-sm) !important;
271
+ }
272
+
273
+ .icon-button {
274
+ flex: 0 0 auto;
275
+ width: 2.5rem;
276
+ min-width: 2.5rem;
277
+ padding: 0;
278
+ display: inline-flex;
279
+ align-items: center;
280
+ justify-content: center;
281
}
282
283
.spinning {
@@ -317,17 +288,6 @@
288
from { transform: rotate(0deg); }
289
to { transform: rotate(360deg); }
290
}
320
-
321
- @media (max-width: 640px) {
322
- .ssb-control-actions {
323
- margin-left: 0;
324
- width: 100%;
325
- }
326
-
327
- .ssb-control-actions .button {
328
- flex: 1 1 0;
329
- }
330
- }
291
</style>
292
</body>
293
</html>
skills/a0-development/SKILL.md
+2
@@ -455,6 +455,8 @@ class MyEndpoint(ApiHandler):
455
456
Agent profiles define specialized subordinates with custom prompts and behaviors.
457
458
+> For a guided, step-by-step wizard (scope selection, `agent.yaml` schema, prompt overrides, tool/extension stubs, test checklist) use the dedicated `/a0/skills/a0-new-agent/SKILL.md` skill.
459
+
460
### Profile Directory Structure
461
462
```
skills/a0-new-agent/SKILL.md
new
+200
@@ -0,0 +1,200 @@
1
+---
2
+name: a0-new-agent
3
+description: Create a new Agent Zero agent profile (subordinate). Covers where profiles live (user / plugin-distributed / project-scoped), the agent.yaml schema, the prompt inheritance & override model, and optional profile-specific tools and extensions. Use for any "create/add/new agent profile" request.
4
+version: 1.0.0
5
+tags: ["agents", "profile", "create", "new", "subordinate"]
6
+trigger_patterns:
7
+ - "create agent profile"
8
+ - "new agent profile"
9
+ - "add agent profile"
10
+ - "make agent profile"
11
+ - "new subordinate agent"
12
+ - "create subordinate"
13
+ - "agent profile template"
14
+ - "build agent profile"
15
+---
16
+
17
+# Create an Agent Zero Agent Profile
18
+
19
+> [!IMPORTANT]
20
+> Do **not** create new profiles in `/a0/agents/` — that directory is reserved for core framework profiles (`default`, `agent0`, `developer`, `hacker`, `researcher`, `_example`). User profiles belong in `/a0/usr/agents/<profile_name>/`.
21
+
22
+Related skills: `/a0/skills/a0-development/SKILL.md` (broader framework guide) | `/a0/skills/a0-create-plugin/SKILL.md` (bundle a profile inside a plugin).
23
+
24
+Primary references:
25
+- `/a0/agents/_example/` — the canonical reference profile (tool + extension + prompt overrides)
26
+- `/a0/agents/default/` — the base profile every other profile inherits from
27
+- `/a0/docs/agents/AGENTS.plugins.md` — plugin-distributed profiles + per-profile config
28
+
29
+---
30
+
31
+## Step 0: Ask First — Where should this profile live?
32
+
33
+Before creating anything, ask the user one question:
34
+
35
+> "Where should this profile live?
36
+> 1. **User profile** — `/a0/usr/agents/<name>/` (survives framework updates, this is the normal choice).
37
+> 2. **Plugin-distributed** — shipped with a plugin at `/a0/usr/plugins/<plugin>/agents/<name>/` (for reusable profiles tied to a plugin's tools).
38
+> 3. **Project-scoped** — `project/.a0proj/agents/<name>/` (only available inside that project)."
39
+
40
+Pick the path based on the answer. The rest of the skill uses `<PROFILE_ROOT>` for whichever was chosen.
41
+
42
+---
43
+
44
+## Step 1: Collect the four inputs
45
+
46
+Gather these before writing any files:
47
+
48
+| Input | Rule | Example |
49
+|---|---|---|
50
+| **name** (directory name) | lowercase letters, numbers, hyphens or underscores; must be unique across profile search paths | `data-analyst` |
51
+| **title** | human-readable display name shown in the UI | `Data Analyst` |
52
+| **description** | one-line specialization summary | `Agent specialized in data analysis, visualization, and statistical modeling.` |
53
+| **context** | instructions telling the *superior* agent when to delegate to this profile | `Use this agent for data analysis tasks, creating visualizations, statistical analysis, and working with datasets in Python.` |
54
+
55
+> [!NOTE]
56
+> `agent.yaml` has **only** these three content fields (`title`, `description`, `context`). There is no per-profile model, temperature, or `allowed_tools` setting — model config is handled by the `_model_config` plugin, and tool availability is controlled by plugin activation. Do not invent extra fields.
57
+
58
+---
59
+
60
+## Step 2: Create the directory and `agent.yaml`
61
+
62
+```
63
+<PROFILE_ROOT>/<name>/
64
+├── agent.yaml # Required
65
+├── prompts/ # Optional — prompt overrides
66
+├── tools/ # Optional — profile-specific tools
67
+└── extensions/ # Optional — profile-specific extensions
68
+```
69
+
70
+`agent.yaml`:
71
+
72
+```yaml
73
+title: Data Analyst
74
+description: Agent specialized in data analysis, visualization, and statistical modeling.
75
+context: Use this agent for data analysis tasks, creating visualizations, statistical
76
+ analysis, and working with datasets in Python.
77
+```
78
+
79
+A profile with only `agent.yaml` is valid — it inherits everything from `default/`. Add the sections below only when you need to change something.
80
+
81
+---
82
+
83
+## Step 3: Override prompts (the most common customization)
84
+
85
+Profiles inherit all prompts from `/a0/prompts/default/` and from `/a0/agents/default/prompts/`. To change behavior, drop a file with the **same filename** into `<PROFILE_ROOT>/<name>/prompts/`. The loader searches profile-specific prompts first, then falls back.
86
+
87
+The usual overrides:
88
+
89
+| File | What it controls |
90
+|---|---|
91
+| `agent.system.main.role.md` | The agent's role / identity (most common) |
92
+| `agent.system.main.specifics.md` | Specialized behavioral instructions for this profile |
93
+| `agent.system.main.communication.md` | Communication style / reply format |
94
+| `agent.system.main.environment.md` | Environment & tooling context (e.g. `hacker` uses this for Kali) |
95
+| `agent.system.tool.<name>.md` | Usage instructions for a profile-specific tool |
96
+
97
+Example `agent.system.main.role.md`:
98
+
99
+```markdown
100
+## Your role
101
+
102
+You are a specialized data analysis agent.
103
+Your expertise includes:
104
+- Python data analysis (pandas, numpy, scipy)
105
+- Data visualization (matplotlib, seaborn, plotly)
106
+- Statistical modeling and hypothesis testing
107
+- SQL queries and database analysis
108
+- Data cleaning and preprocessing
109
+```
110
+
111
+> [!TIP]
112
+> Only override what you need. Copying unchanged prompt files creates drift when the framework updates the originals.
113
+
114
+---
115
+
116
+## Step 4 (optional): Profile-specific tools
117
+
118
+Drop a Python tool class in `<PROFILE_ROOT>/<name>/tools/<tool_name>.py`:
119
+
120
+```python
121
+from helpers.tool import Tool, Response
122
+
123
+class ExampleTool(Tool):
124
+ async def execute(self, **kwargs):
125
+ test_input = kwargs.get("test_input", "")
126
+ return Response(
127
+ message=f"Example tool executed with test_input: {test_input}",
128
+ break_loop=False,
129
+ )
130
+```
131
+
132
+Two important rules:
133
+
134
+1. To make the tool visible in the system prompt, add `prompts/agent.system.tool.<tool_name>.md` describing its usage and JSON call schema. The prompt loader auto-includes every file matching `agent.system.tool.*.md`.
135
+2. Placing a file with the same name as a core tool (e.g. `tools/response.py`) **replaces** the core tool for this profile only. See `/a0/agents/_example/tools/response.py` for a redefinition example.
136
+
137
+---
138
+
139
+## Step 5 (optional): Profile-specific extensions
140
+
141
+Lifecycle hooks go in `<PROFILE_ROOT>/<name>/extensions/<hook_point>/_NN_<name>.py`. The `_NN_` prefix controls execution order.
142
+
143
+Example — rename the agent at init (`/a0/agents/_example/extensions/agent_init/_10_example_extension.py`):
144
+
145
+```python
146
+from helpers.extension import Extension
147
+
148
+class ExampleExtension(Extension):
149
+ async def execute(self, **kwargs):
150
+ self.agent.agent_name = "SuperAgent" + str(self.agent.number)
151
+```
152
+
153
+Available hook points mirror the framework's own `/a0/extensions/python/<point>/` directories — see `a0-development/SKILL.md` for the full list.
154
+
155
+---
156
+
157
+## Step 6: Test the new profile
158
+
159
+1. The profile is picked up on next agent initialization — no restart of individual conversations needed, but a fresh agent/subordinate spawn is required.
160
+2. From the superior agent, delegate to it via `call_subordinate` using the profile's **directory name** (not the title).
161
+3. Verify:
162
+ - Title appears correctly in the UI agent selector.
163
+ - Role override (if any) takes effect in the new agent's system prompt.
164
+ - Profile-specific tools are callable and their prompt files are included.
165
+
166
+If the profile does not appear, check:
167
+- Directory name matches the `^[a-z0-9_-]+$` pattern and is unique.
168
+- `agent.yaml` parses as valid YAML.
169
+- It is placed in one of the recognized search paths (see Step 0).
170
+
171
+---
172
+
173
+## Reference: Complete `_example` profile layout
174
+
175
+```
176
+/a0/agents/_example/
177
+├── agent.yaml
178
+├── prompts/
179
+│ ├── agent.system.main.specifics.md # role override
180
+│ └── agent.system.tool.example_tool.md # tool usage prompt
181
+├── tools/
182
+│ ├── example_tool.py # new tool
183
+│ └── response.py # redefines core response tool
184
+└── extensions/
185
+ └── agent_init/
186
+ └── _10_example_extension.py # init-time hook
187
+```
188
+
189
+Copy this shape when in doubt — it demonstrates every customization surface a profile supports.
190
+
191
+---
192
+
193
+## Quick checklist
194
+
195
+- [ ] Confirmed profile scope (user / plugin / project)
196
+- [ ] Directory name is unique and matches allowed characters
197
+- [ ] `agent.yaml` contains exactly `title`, `description`, `context`
198
+- [ ] Prompt overrides only include files that actually change behavior
199
+- [ ] Any new tool has a matching `agent.system.tool.<name>.md`
200
+- [ ] Profile tested via `call_subordinate` in a fresh conversation
tests/test_skills_runtime.py
new
+171
@@ -0,0 +1,171 @@
1
+import importlib.util
2
+import sys
3
+import types
4
+from pathlib import Path
5
+
6
+import pytest
7
+
8
+
9
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
10
+SKILLS_HELPER_PATH = PROJECT_ROOT / "helpers" / "skills.py"
11
+
12
+
13
+def _register_helpers_stubs():
14
+ helpers_pkg = types.ModuleType("helpers")
15
+ helpers_pkg.__path__ = []
16
+
17
+ files = types.ModuleType("helpers.files")
18
+ files.normalize_a0_path = lambda path: str(path).replace("\\", "/")
19
+ files.fix_dev_path = lambda path: str(path).replace("\\", "/")
20
+ files.get_abs_path = lambda *parts: "/" + "/".join(str(part).strip("/") for part in parts if part)
21
+ files.exists = lambda path: False
22
+ files.is_in_dir = lambda path, root: str(path).startswith(str(root))
23
+ files.find_existing_paths_by_pattern = lambda pattern: []
24
+ files.read_file = lambda path: ""
25
+
26
+ projects = types.ModuleType("helpers.projects")
27
+ projects.get_context_project_name = lambda context: context.get_data("project")
28
+ projects.get_project_meta = lambda project_name, *parts: (
29
+ f"/projects/{project_name}/" + "/".join(str(part).strip("/") for part in parts if part)
30
+ if project_name
31
+ else ""
32
+ )
33
+
34
+ plugins = types.ModuleType("helpers.plugins")
35
+ plugins.get_plugin_config = lambda *args, **kwargs: {}
36
+ plugins.get_enabled_plugin_paths = lambda *args, **kwargs: []
37
+
38
+ subagents = types.ModuleType("helpers.subagents")
39
+ subagents.get_paths = lambda agent, *parts: []
40
+
41
+ file_tree = types.ModuleType("helpers.file_tree")
42
+ file_tree.file_tree = lambda *args, **kwargs: ""
43
+
44
+ runtime = types.ModuleType("helpers.runtime")
45
+ runtime.is_development = lambda: False
46
+
47
+ helpers_pkg.files = files
48
+ helpers_pkg.projects = projects
49
+ helpers_pkg.plugins = plugins
50
+ helpers_pkg.subagents = subagents
51
+ helpers_pkg.file_tree = file_tree
52
+ helpers_pkg.runtime = runtime
53
+
54
+ sys.modules["helpers"] = helpers_pkg
55
+ sys.modules["helpers.files"] = files
56
+ sys.modules["helpers.projects"] = projects
57
+ sys.modules["helpers.plugins"] = plugins
58
+ sys.modules["helpers.subagents"] = subagents
59
+ sys.modules["helpers.file_tree"] = file_tree
60
+ sys.modules["helpers.runtime"] = runtime
61
+
62
+
63
+def _load_skills_helper_module():
64
+ _register_helpers_stubs()
65
+ spec = importlib.util.spec_from_file_location("test_skills_helper_module", SKILLS_HELPER_PATH)
66
+ module = importlib.util.module_from_spec(spec)
67
+ assert spec and spec.loader
68
+ sys.modules[spec.name] = module
69
+ spec.loader.exec_module(module)
70
+ return module
71
+
72
+
73
+runtime = _load_skills_helper_module()
74
+
75
+
76
+class DummyContext:
77
+ def __init__(self):
78
+ self.data = {}
79
+
80
+ def get_data(self, key, recursive=True):
81
+ return self.data.get(key)
82
+
83
+ def set_data(self, key, value, recursive=True):
84
+ self.data[key] = value
85
+
86
+
87
+class DummyAgent:
88
+ def __init__(self):
89
+ self.context = DummyContext()
90
+ self.data = {}
91
+
92
+
93
+def _scope_config(entries):
94
+ return {"active_skills": entries}
95
+
96
+
97
+def test_active_skills_cap_is_twenty():
98
+ assert runtime.MAX_ACTIVE_SKILLS == 20
99
+ assert runtime.get_max_active_skills() == 20
100
+
101
+
102
+def test_chat_activation_can_override_scope_defaults(monkeypatch):
103
+ monkeypatch.setattr(
104
+ runtime.plugin_helpers,
105
+ "get_plugin_config",
106
+ lambda *args, **kwargs: _scope_config([{"name": "Pinned"}]),
107
+ )
108
+ agent = DummyAgent()
109
+
110
+ assert [entry["name"] for entry in runtime.get_active_skills(agent)] == [
111
+ "Pinned"
112
+ ]
113
+
114
+ runtime.activate_chat_skill(agent, {"name": "Extra"})
115
+ assert [entry["name"] for entry in runtime.get_active_skills(agent)] == [
116
+ "Pinned",
117
+ "Extra",
118
+ ]
119
+ assert [entry["name"] for entry in runtime.get_chat_active_skills(agent.context)] == [
120
+ "Extra"
121
+ ]
122
+
123
+ runtime.deactivate_chat_skill(agent, {"name": "Pinned"})
124
+ assert [entry["name"] for entry in runtime.get_active_skills(agent)] == [
125
+ "Extra"
126
+ ]
127
+ assert [entry["name"] for entry in runtime.get_chat_disabled_skills(agent.context)] == [
128
+ "Pinned"
129
+ ]
130
+
131
+ runtime.activate_chat_skill(agent, {"name": "Pinned"})
132
+ assert [entry["name"] for entry in runtime.get_active_skills(agent)] == [
133
+ "Pinned",
134
+ "Extra",
135
+ ]
136
+ assert runtime.get_chat_disabled_skills(agent.context) == []
137
+
138
+
139
+def test_clearing_chat_overrides_restores_scope_defaults(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
+ runtime.activate_chat_skill(agent, {"name": "Extra"})
147
+ runtime.deactivate_chat_skill(agent, {"name": "Pinned"})
148
+
149
+ runtime.clear_chat_skill_overrides(agent)
150
+
151
+ assert [entry["name"] for entry in runtime.get_active_skills(agent)] == [
152
+ "Pinned"
153
+ ]
154
+ assert runtime.get_chat_active_skills(agent.context) == []
155
+ assert runtime.get_chat_disabled_skills(agent.context) == []
156
+
157
+
158
+def test_activating_new_skill_fails_once_limit_is_full(monkeypatch):
159
+ monkeypatch.setattr(
160
+ runtime.plugin_helpers,
161
+ "get_plugin_config",
162
+ lambda *args, **kwargs: _scope_config(
163
+ [{"name": f"Pinned {index}"} for index in range(20)]
164
+ ),
165
+ )
166
+ agent = DummyAgent()
167
+
168
+ with pytest.raises(ValueError, match="at most 20"):
169
+ runtime.activate_chat_skill(agent, {"name": "Overflow"})
170
+
171
+ assert len(runtime.get_active_skills(agent)) == 20
tools/skills_tool.py
+2
-4
@@ -1,11 +1,9 @@
1
from __future__ import annotations
2
3
-from pathlib import Path
3
from typing import List
4
5
from helpers.tool import Tool, Response
7
-from helpers import projects, files, file_tree
8
-from helpers import skills as skills_helper, runtime
6
+from helpers import skills as skills_helper
7
from helpers.print_style import PrintStyle
8
9
@@ -197,4 +195,4 @@ class SkillsTool(Tool):
195
196
197
def max_loaded_skills() -> int:
200
- return 5 # TODO move to settings
198
+ return skills_helper.MAX_ACTIVE_SKILLS
webui/components/modals/markdown/markdown-modal.html
+18
-1
@@ -9,6 +9,7 @@
9
<div x-data>
10
<template x-if="$store.markdownModal">
11
<div x-create="$el.closest('.modal')?.querySelector('.modal-title') && ($el.closest('.modal').querySelector('.modal-title').textContent = $store.markdownModal.title)"
12
+ x-init="$store.markdownModal.onOpen()"
13
x-destroy="$store.markdownModal.cleanup()"
14
class="md-modal-root">
15
@@ -18,9 +19,14 @@
19
</div>
20
21
<div class="md-modal-body msg-content"
21
- x-show="!$store.markdownModal.error"
22
+ x-show="!$store.markdownModal.error && !$store.markdownModal.isAce"
23
x-html="$store.markdownModal.renderedHtml">
24
</div>
25
+
26
+ <div id="markdown-ace-viewer-container"
27
+ class="md-modal-ace no-scrollbar"
28
+ x-show="!$store.markdownModal.error && $store.markdownModal.isAce">
29
+ </div>
30
</div>
31
</template>
32
</div>
@@ -42,6 +48,17 @@
48
max-height: 72vh;
49
}
50
51
+ .md-modal-ace {
52
+ width: 100%;
53
+ height: 72vh;
54
+ border-radius: 0.4rem;
55
+ overflow: auto;
56
+ }
57
+
58
+ .md-modal-root {
59
+ overflow: hidden;
60
+ }
61
+
62
.md-modal-error {
63
display: flex;
64
align-items: center;
webui/components/modals/markdown/markdown-store.js
+59
-1
@@ -5,11 +5,15 @@ export const store = createStore("markdownModal", {
5
title: "",
6
content: "",
7
error: null,
8
+ viewer: "rendered",
9
+ editor: null,
10
9
- open(title, content) {
11
+ open(title, content, options = {}) {
12
this.title = title;
13
this.content = content;
14
this.error = null;
15
+ this.viewer = options.viewer || "rendered";
16
+ this.destroyEditor();
17
},
18
19
get renderedHtml() {
@@ -17,9 +21,63 @@ export const store = createStore("markdownModal", {
21
return renderSafeMarkdown(this.content);
22
},
23
24
+ get isAce() {
25
+ return this.viewer === "ace";
26
+ },
27
+
28
+ onOpen() {
29
+ if (this.isAce) {
30
+ this.scheduleEditorInit();
31
+ }
32
+ },
33
+
34
+ scheduleEditorInit() {
35
+ window.requestAnimationFrame(() => {
36
+ if (!this.isAce || this.error) return;
37
+ window.requestAnimationFrame(() => this.initEditor());
38
+ });
39
+ },
40
+
41
+ initEditor() {
42
+ const container = document.getElementById("markdown-ace-viewer-container");
43
+ if (!container) return;
44
+
45
+ this.destroyEditor();
46
+
47
+ if (!window.ace?.edit) {
48
+ this.error = "Editor library not loaded";
49
+ return;
50
+ }
51
+
52
+ const editor = window.ace.edit("markdown-ace-viewer-container");
53
+ if (!editor) {
54
+ this.error = "Failed to initialize editor";
55
+ return;
56
+ }
57
+
58
+ const darkMode = window.localStorage?.getItem("darkMode");
59
+ const theme = darkMode !== "false" ? "ace/theme/github_dark" : "ace/theme/tomorrow";
60
+
61
+ this.editor = editor;
62
+ this.editor.setTheme(theme);
63
+ this.editor.session.setMode("ace/mode/markdown");
64
+ this.editor.setValue(this.content || "", -1);
65
+ this.editor.setReadOnly(true);
66
+ this.editor.clearSelection();
67
+ },
68
+
69
+ destroyEditor() {
70
+ if (this.editor?.destroy) {
71
+ this.editor.destroy();
72
+ }
73
+ this.editor = null;
74
+ },
75
+
76
cleanup() {
77
+ this.destroyEditor();
78
this.title = "";
79
this.content = "";
80
this.error = null;
81
+ this.viewer = "rendered";
82
},
83
});