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 });