Load skills into chat history

Switch the Skills catalog UI/API from scope-wide prompt pins to current-chat history loading, and remove user-facing removal of loaded skills. Disable legacy active-skill prompt injection so forgotten scope config no longer inflates new-chat prompts.

Alessandro committed Jul 8, 2026 at 01:52 UTC dea64ddad083d828aede004d42e926f4e4fd1015
12 files changed +293 -217
extensions/python/message_loop_prompts_after/AGENTS.md
+1 -1
@@ -7,9 +7,9 @@
7 ## Ownership
8
9 - Ordered Python files own current datetime, skill recall/load context, agent info, parallel job status, and workdir extras injection.
10 -- Active skill instructions belong in prompt protocol.
10 - Explicitly loaded skill bodies belong in tool-result history with metadata so they can survive persistence and be reattached after compaction.
11 - Explicitly loaded skill IDs are chat-wide context data, not agent-local state.
12 +- Legacy active-skill prompt protocol injection must stay empty; selected skills are loaded through history.
13
14 ## Local Contracts
15
helpers/skills.py
+1 -3
@@ -1132,7 +1132,6 @@ def hide_chat_skill(agent: Agent, entry: Any) -> list[ActiveSkillEntry]:
1132 CONTEXT_DATA_NAME_CHAT_VISIBLE_SKILLS,
1133 visible_entries,
1134 )
1135 - unload_agent_skill(agent, normalized)
1135 return get_hidden_skills(agent)
1136
1137
@@ -1183,8 +1182,7 @@ def clear_chat_skill_overrides(agent: Agent) -> list[ActiveSkillEntry]:
1182
1183
1184 def build_active_skills_prompt(agent: Agent | None) -> str:
1186 - items = _resolve_active_skill_entries(agent, get_active_skills(agent))
1187 - return "\n\n".join(item["content"] for item in items if item.get("content")).strip()
1185 + return ""
1186
1187
1188 def _format_skill_prompt(skill: Skill) -> str:
helpers/skills.py.dox.md
+2
@@ -56,6 +56,8 @@
56 - Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
57 - Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
58 - Loaded skill names are chat-wide context data under `CONTEXT_DATA_NAME_LOADED_SKILLS`; legacy agent-local `loaded_skills` lists are migrated into context data and cleared when read.
59 +- Loaded skill bodies live in chat history; hiding a skill changes catalog visibility but does not remove the loaded-skill ledger.
60 +- `build_active_skills_prompt()` returns empty because selected skills are loaded through history, not prompt protocol.
61 - Invalid `SKILL.md` frontmatter emits a once-per-path scan warning with the skipped skill path/name and a line number when the parser can identify one directly.
62 - Observed side-effect areas: filesystem reads, filesystem deletion, plugin state, settings/state persistence, context data, secret handling.
63 - Imported dependency areas include: `__future__`, `dataclasses`, `helpers`, `os`, `pathlib`, `re`, `typing`.
plugins/_skills/AGENTS.md
+8 -7
@@ -2,25 +2,26 @@
2
3 ## Purpose
4
5 -- Own active and hidden skill configuration injected into prompt protocol on each turn.
5 +- Own current-chat skill loading and hidden skill configuration.
6
7 ## Ownership
8
9 -- `hooks.py` owns skill prompt injection and plugin lifecycle behavior.
10 -- `api/skills_catalog.py` owns skill catalog access.
11 -- `prompts/agent.system.active_skills.md` owns injected active-skill prompt content.
9 +- `hooks.py` owns skill config normalization.
10 +- `api/skills_catalog.py` owns skill catalog access and loading selected skills into chat history.
11 +- `prompts/agent.system.active_skills.md` is retained only for legacy prompt-protocol compatibility.
12 - `webui/` owns skill settings UI and store.
13 - `default_config.yaml`, `plugin.yaml`, `README.md`, and `LICENSE` own defaults, metadata, docs, and license.
14
15 ## Local Contracts
16
17 -- Keep active skill lists bounded by configured caps.
17 +- Skills selected in `webui/` load into the current chat history only; do not store them as scope defaults.
18 +- Loaded skills are append-only from the user UI because their instructions live in chat history.
19 - Store configured skills in normalized portable paths.
19 -- Hidden skills affect catalog/search/load visibility but must not be injected as active prompt content.
20 +- Hidden skills affect catalog/search/load visibility but must not remove loaded skill history.
21
22 ## Work Guidance
23
23 -- Coordinate active-skill resolution changes with core skill loading and settings UI.
24 +- Coordinate skill loading changes with `skills_tool`, loaded-skill history reattachment, and settings UI.
25
26 ## Verification
27
plugins/_skills/README.md
+11 -12
@@ -1,29 +1,28 @@
1 # Skills
2
3 -Skills is a built-in Agent Zero plugin that manages active skills across scope defaults and the current chat.
3 +Skills is a built-in Agent Zero plugin that manages skill loading and visibility for the current chat.
4
5 ## What It Does
6
7 -- pins default skills for the current plugin scope
7 +- loads selected skills into current-chat history
8 - hides noisy skills from the model-facing available catalog, skill search, and load access
9 -- injects the effective active skills into prompt protocol on every turn
10 -- extends the same config screen with a current-chat mode so users can activate or hide skills live per conversation
9 +- shows loaded skills without offering removal, because loaded skill bodies are part of chat history
10 +- lets users hide or show skills live per conversation
11 - supports global and project scoped configurations without agent-profile variants
12 - links directly to the built-in Skills list
13 - links directly to the active project's Skills section when a project is active
14
15 ## Why This Exists
16
17 -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 +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 use that same history-backed skill loading from the Skills screen.
18
19 Skills fills that gap as a bundled built-in plugin.
20 -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 +The shared skill discovery and loaded-skill ledger live in `helpers/skills.py`, and this plugin focuses on catalog UI, chat loading, and visibility.
21
22 ## Notes
23
24 -- keep the active list short because every active skill is injected into prompt protocol every turn
25 -- the default cap is 20 active skills, and it can be raised or lowered in Skills plugin config
26 -- hidden skills are not capped because they are stored as control data, not injected into the prompt
27 -- selected skills are stored in normalized `/a0/...` form so configs stay portable across development and Docker-style layouts
28 -- scope defaults can be hidden or supplemented per chat without creating a new conversation
29 -- if a configured skill is not visible in the current agent scope, it is skipped quietly instead of breaking the prompt build
24 +- selected skills are appended to current-chat history using the same metadata shape as `skills_tool`
25 +- loaded skills are not removable from the UI; future context compaction may reattach their bodies from the loaded-skill ledger
26 +- hidden skills are stored as control data, not injected into the prompt
27 +- hidden skill paths are stored in normalized `/a0/...` form so configs stay portable across development and Docker-style layouts
28 +- if a configured hidden skill is not visible in the current agent scope, it is skipped quietly instead of breaking catalog builds
plugins/_skills/api/skills_catalog.py
+55 -38
@@ -37,17 +37,36 @@ class SkillsCatalog(ApiHandler):
37 def _activate(self, input: dict, *, context_id: str) -> dict[str, Any]:
38 context = self._require_context(context_id)
39 skill_entry = self._require_skill_entry(input)
40 - skills.activate_chat_skill(context.get_agent(), skill_entry)
40 + agent = context.get_agent()
41 + skill = self._resolve_catalog_skill(skill_entry, context=context)
42 + skill_name = str(skill.get("name") or skill_entry.get("name") or "").strip()
43 + if not skill_name:
44 + raise ValueError("Skill name is required")
45 +
46 + skill_path = str(skill.get("path") or skill_entry.get("path") or "").strip()
47 + skills.add_loaded_skill_name(agent, skill_name)
48 + if not self._visible_skill_loaded(agent, skill_name):
49 + content = skills.load_skill_for_agent(skill_name=skill_name, agent=agent)
50 + if content.startswith("Error:"):
51 + raise ValueError(content)
52 + agent.hist_add_tool_result(
53 + "skills_tool",
54 + content,
55 + skill_instructions={
56 + "name": skill_name,
57 + "path": skill_path,
58 + "source": "skills_page:load",
59 + "content_included": True,
60 + },
61 + )
62 save_tmp_chat(context)
63 return self._build_state(context_id=context.id)
64
65 def _deactivate(self, input: dict, *, context_id: str) -> dict[str, Any]:
45 - context = self._require_context(context_id)
46 - skill_entry = self._require_skill_entry(input)
47 - skills.deactivate_chat_skill(context.get_agent(), skill_entry)
48 - skills.unload_agent_skill(context.get_agent(), skill_entry)
49 - save_tmp_chat(context)
50 - return self._build_state(context_id=context.id)
66 + return {
67 + "ok": False,
68 + "error": "Loaded skills are kept in chat history and cannot be removed.",
69 + }
70
71 def _hide(self, input: dict, *, context_id: str) -> dict[str, Any]:
72 context = self._require_context(context_id)
@@ -64,10 +83,10 @@ class SkillsCatalog(ApiHandler):
83 return self._build_state(context_id=context.id)
84
85 def _clear(self, *, context_id: str) -> dict[str, Any]:
67 - context = self._require_context(context_id)
68 - skills.clear_chat_skill_overrides(context.get_agent())
69 - save_tmp_chat(context)
70 - return self._build_state(context_id=context.id)
86 + return {
87 + "ok": False,
88 + "error": "Loaded skills are kept in chat history and cannot be removed.",
89 + }
90
91 def _build_state(
92 self,
@@ -87,26 +106,13 @@ class SkillsCatalog(ApiHandler):
106 str(skill.get("name") or "").strip().lower(): skill for skill in catalog
107 }
108
109 + loaded_entries = skills.get_loaded_skill_entries(agent)
110 scope_entries = skills.get_scope_active_skills(agent)
111 scope_hidden_entries = skills.get_scope_hidden_skills(agent)
112 chat_entries = skills.get_chat_active_skills(context)
113 disabled_entries = skills.get_chat_disabled_skills(context)
114 visible_entries = skills.get_chat_visible_skills(context)
115 hidden_entries = skills.get_hidden_skills(agent)
96 - active_entries = self._merge_entries(
97 - skills.get_active_skills(agent),
98 - self._filter_hidden_entries(
99 - self._get_loaded_skill_entries(agent),
100 - hidden_entries,
101 - ),
102 - )
103 -
104 - scope_keys = {
105 - self._entry_key(entry) for entry in scope_entries if self._entry_key(entry)
106 - }
107 - chat_keys = {
108 - self._entry_key(entry) for entry in chat_entries if self._entry_key(entry)
109 - }
116
117 return {
118 "ok": True,
@@ -123,15 +129,9 @@ class SkillsCatalog(ApiHandler):
129 entry,
130 catalog_by_key,
131 catalog_by_name,
126 - state_source=(
127 - "Pinned + chat"
128 - if key in scope_keys and key in chat_keys
129 - else "Pinned default"
130 - if key in scope_keys
131 - else "Chat"
132 - ),
132 + state_source="Loaded in chat history",
133 )
134 - for entry in active_entries
134 + for entry in loaded_entries
135 if (key := self._entry_key(entry))
136 ],
137 "scope_skills": [
@@ -139,7 +139,7 @@ class SkillsCatalog(ApiHandler):
139 entry,
140 catalog_by_key,
141 catalog_by_name,
142 - state_source="Pinned default",
142 + state_source="Scope default",
143 )
144 for entry in scope_entries
145 ],
@@ -253,11 +253,28 @@ class SkillsCatalog(ApiHandler):
253 def _entry_key(self, entry: dict[str, Any]) -> str:
254 return str(entry.get("path") or entry.get("name") or "").strip().lower()
255
256 - def _get_loaded_skill_entries(self, agent: Any | None) -> list[dict[str, str]]:
257 - if not agent:
258 - return []
256 + def _visible_skill_loaded(self, agent: Any, skill_name: str) -> bool:
257 + output = getattr(getattr(agent, "history", None), "output", None)
258 + if not callable(output):
259 + return False
260 + return any(
261 + skills.skill_instruction_name(message) == skill_name
262 + for message in output()
263 + )
264
260 - return skills.get_loaded_skill_entries(agent)
265 + def _resolve_catalog_skill(
266 + self,
267 + entry: dict[str, Any],
268 + *,
269 + context: AgentContext,
270 + ) -> dict[str, Any]:
271 + agent = context.get_agent()
272 + project_name = projects.get_context_project_name(context) or ""
273 + catalog = skills.list_skill_catalog(project_name=project_name, agent=agent)
274 + return next(
275 + (item for item in catalog if self._entry_matches_any(entry, [item])),
276 + entry,
277 + )
278
279 def _merge_entries(
280 self,
plugins/_skills/plugin.yaml
+1 -1
@@ -1,6 +1,6 @@
1 name: _skills
2 title: Skills
3 -description: Pin skills into prompt protocol on every turn.
3 +description: Load skills into the current chat history.
4 version: 1.0.0
5 always_enabled: true
6 settings_sections:
plugins/_skills/prompts/agent.system.active_skills.md
+1 -1
@@ -1,5 +1,5 @@
1 ## active skills
2 -The following skills were manually activated by the User.
2 +The following skills were explicitly activated for this chat.
3 Treat them as already loaded instructions and follow them when relevant.
4
5 {{skills}}
plugins/_skills/webui/config-store.js
+18 -121
@@ -1,27 +1,9 @@
1 import * as API from "/js/api.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 {
5 - toastFrontendError,
6 - toastFrontendInfo,
7 -} from "/components/notifications/notification-store.js";
4 +import { toastFrontendError } from "/components/notifications/notification-store.js";
5
6 const CATALOG_API = "/plugins/_skills/skills_catalog";
10 -const MAX_ACTIVE_SKILLS_FALLBACK = 20;
11 -
12 -function normalizeMaxActiveSkills(value) {
13 - if (typeof value === "boolean") return MAX_ACTIVE_SKILLS_FALLBACK;
14 -
15 - const numeric = typeof value === "number"
16 - ? value
17 - : Number.parseInt(String(value ?? "").trim(), 10);
18 -
19 - if (!Number.isFinite(numeric) || numeric < 1) {
20 - return MAX_ACTIVE_SKILLS_FALLBACK;
21 - }
22 -
23 - return Math.floor(numeric);
24 -}
7
8 function normalizeEntry(entry) {
9 if (!entry) return null;
@@ -62,11 +44,8 @@ function entriesMatch(left, right) {
44
45 function ensureConfig(config) {
46 if (!config || typeof config !== "object") return;
65 - config.max_active_skills = normalizeMaxActiveSkills(config.max_active_skills);
66 - const activeSkills = Array.isArray(config.active_skills) ? config.active_skills : [];
47 const hiddenSkills = Array.isArray(config.hidden_skills) ? config.hidden_skills : [];
48
69 - config.active_skills = compactEntries(activeSkills, config.max_active_skills);
49 config.hidden_skills = compactEntries(hiddenSkills);
50 }
51
@@ -94,15 +73,13 @@ window.createSkillsConfigModel = (context, config) => ({
73 mutatingChat: false,
74 catalog: [],
75 search: "",
97 - maxActiveSkills: MAX_ACTIVE_SKILLS_FALLBACK,
76 selectedSkills: [],
77 hiddenSkills: [],
78 chatContextAvailable: false,
79
80 initDefaults() {
81 ensureConfig(config);
104 - this.maxActiveSkills = config.max_active_skills;
105 - this.selectedSkills = [...this.activeEntries];
82 + this.selectedSkills = [];
83 this.hiddenSkills = [...this.hiddenEntries];
84 },
85
@@ -110,50 +87,11 @@ window.createSkillsConfigModel = (context, config) => ({
87 return context?.openOptions?.focus === "chat";
88 },
89
113 - get activeEntries() {
114 - ensureConfig(config);
115 - return config.active_skills;
116 - },
117 -
90 get hiddenEntries() {
91 ensureConfig(config);
92 return config.hidden_skills;
93 },
94
123 - get selectedCount() {
124 - return this.selectedSkills.length;
125 - },
126 -
127 - async applyMaxActiveSkills(value, { notify = false } = {}) {
128 - ensureConfig(config);
129 -
130 - const nextLimit = normalizeMaxActiveSkills(value);
131 - const previousLimit = this.maxActiveSkills;
132 - const previousCount = this.selectedSkills.length;
133 -
134 - config.max_active_skills = nextLimit;
135 - this.maxActiveSkills = nextLimit;
136 -
137 - if (previousCount > nextLimit) {
138 - this._setSelectedSkills(this.selectedSkills.slice(0, nextLimit));
139 -
140 - if (notify) {
141 - await toastFrontendInfo(
142 - `Trimmed ${previousCount - nextLimit} pinned skill${previousCount - nextLimit === 1 ? "" : "s"} to match the new cap of ${nextLimit}.`,
143 - "Skills"
144 - );
145 - }
146 - return;
147 - }
148 -
149 - if (notify && previousLimit !== nextLimit) {
150 - await toastFrontendInfo(
151 - `Pinned skill cap set to ${nextLimit}.`,
152 - "Skills"
153 - );
154 - }
155 - },
156 -
95 get catalogMap() {
96 const byKey = new Map();
97 for (const skill of this.catalog) {
@@ -188,15 +126,13 @@ window.createSkillsConfigModel = (context, config) => ({
126 },
127
128 pinnedSubtitle() {
191 - return this.isChatMode
192 - ? "These skills are currently pinned into the prompt for this chat."
193 - : "These skills are pinned by default in this scope.";
129 + return "These skills are loaded into the current chat history.";
130 },
131
132 allSkillsSubtitle() {
133 return this.isChatMode
198 - ? "Check a skill to pin it for this chat. Use the eye control to hide or show it in this chat."
199 - : "Check a skill to pin it by default. Use the eye control to hide or show it in this scope.";
134 + ? "Check a skill to load it into this chat. Use the eye control to hide or show it in this chat."
135 + : "Check a skill to load it into the current chat. Use the eye control to hide or show it in this scope.";
136 },
137
138 hiddenStateLabel() {
@@ -223,7 +159,7 @@ window.createSkillsConfigModel = (context, config) => ({
159 },
160
161 isPinDisabled(skill) {
226 - return this.mutatingChat || (!this.isSelected(skill) && this.selectedCount >= this.maxActiveSkills);
162 + return this.mutatingChat || !this.chatContextAvailable || this.isSelected(skill);
163 },
164
165 isVisibilityDisabled() {
@@ -260,7 +196,7 @@ window.createSkillsConfigModel = (context, config) => ({
196 return name ? this.catalogMap.get(name) || null : null;
197 },
198
263 - _setSelectedSkills(entries, { writeConfig = true } = {}) {
199 + _setSelectedSkills(entries) {
200 const normalized = [];
201 const seen = new Set();
202
@@ -270,13 +206,9 @@ window.createSkillsConfigModel = (context, config) => ({
206 if (!item || !key || seen.has(key)) continue;
207 seen.add(key);
208 normalized.push(item);
273 - if (normalized.length >= this.maxActiveSkills) break;
209 }
210
211 this.selectedSkills = normalized;
277 - if (writeConfig) {
278 - config.active_skills = compactEntries(normalized, this.maxActiveSkills);
279 - }
212 },
213
214 _setHiddenSkills(entries, { writeConfig = true } = {}) {
@@ -313,54 +245,28 @@ window.createSkillsConfigModel = (context, config) => ({
245 },
246
247 async togglePinnedSkill(skill, selected) {
316 - const nextEntries = this.selectedSkills.filter((entry) => !entriesMatch(entry, skill));
317 -
318 - if (selected) {
319 - if (this.selectedCount >= this.maxActiveSkills && !this.isSelected(skill)) {
320 - await toastFrontendInfo(
321 - `You can activate at most ${this.maxActiveSkills} skills.`,
322 - "Skills"
323 - );
324 - return;
325 - }
326 -
327 - nextEntries.push({
328 - name: String(skill.name || "").trim(),
329 - path: String(skill.path || "").trim(),
330 - });
248 + if (!selected || this.isSelected(skill)) {
249 + await this.loadCatalog();
250 + return;
251 }
252
333 - this._setSelectedSkills(nextEntries, { writeConfig: !this.isChatMode });
334 -
335 - if (this.isChatMode && this.chatContextAvailable) {
336 - await this.submitChatAction(selected ? "activate" : "deactivate", skill);
253 + if (!this.chatContextAvailable) {
254 + await toastFrontendError("Open a chat before loading a skill.", "Skills");
255 + await this.loadCatalog();
256 + return;
257 }
338 - },
258
340 - async removeEntry(entry) {
341 - await this.togglePinnedSkill(entry, false);
342 - },
343 -
344 - async clearSelections() {
345 - const previous = [...this.selectedSkills];
346 - this._setSelectedSkills([], { writeConfig: !this.isChatMode });
347 - if (this.isChatMode && this.chatContextAvailable) {
348 - for (const entry of previous) {
349 - await this.submitChatAction("deactivate", entry);
350 - }
351 - }
259 + await this.submitChatAction("activate", skill);
260 },
261
262 applyCatalogState(response) {
263 this.chatContextAvailable = !!response?.context_available;
264 const activeFromChat = Array.isArray(response?.active_skills) ? response.active_skills : null;
357 - const activeFromConfig = this.activeEntries;
265 const hiddenFromChat = Array.isArray(response?.hidden_skills) ? response.hidden_skills : null;
266 const hiddenFromConfig = this.hiddenEntries;
267
268 this._setSelectedSkills(
362 - this.isChatMode ? activeFromChat || [] : activeFromConfig,
363 - { writeConfig: !this.isChatMode },
269 + activeFromChat || [],
270 );
271 this._setHiddenSkills(
272 this.isChatMode ? hiddenFromChat || [] : hiddenFromConfig,
@@ -374,7 +280,7 @@ window.createSkillsConfigModel = (context, config) => ({
280 const response = await API.callJsonApi(CATALOG_API, {
281 action: "list",
282 project_name: context.projectName || "",
377 - context_id: this.isChatMode ? chatsStore.selectedContext?.id || "" : "",
283 + context_id: chatsStore.selectedContext?.id || "",
284 });
285
286 if (!response?.ok) {
@@ -382,17 +288,12 @@ window.createSkillsConfigModel = (context, config) => ({
288 }
289
290 this.catalog = Array.isArray(response.skills) ? response.skills : [];
385 - this.maxActiveSkills = normalizeMaxActiveSkills(response.max_active_skills);
386 - if (!this.isChatMode) {
387 - config.max_active_skills = this.maxActiveSkills;
388 - }
291 this.applyCatalogState(response);
292 } catch (error) {
293 this.catalog = [];
294 ensureConfig(config);
393 - this.maxActiveSkills = config.max_active_skills;
295 this.chatContextAvailable = false;
395 - this._setSelectedSkills(this.activeEntries);
296 + this._setSelectedSkills([]);
297 this._setHiddenSkills(this.hiddenEntries);
298 await toastFrontendError(error?.message || "Failed to load skills", "Skills");
299 } finally {
@@ -422,10 +323,6 @@ window.createSkillsConfigModel = (context, config) => ({
323 }
324
325 this.catalog = Array.isArray(response.skills) ? response.skills : this.catalog;
425 - this.maxActiveSkills = normalizeMaxActiveSkills(response.max_active_skills);
426 - if (!this.isChatMode) {
427 - config.max_active_skills = this.maxActiveSkills;
428 - }
326 this.chatContextAvailable = !!response.context_available;
327 this.applyCatalogState(response);
328 return true;
plugins/_skills/webui/config.html
+3 -33
@@ -15,26 +15,6 @@
15 <div class="skills-layout">
16 <div class="section-title">Skills</div>
17
18 - <template x-if="!isChatMode">
19 - <div class="field">
20 - <div class="field-label">
21 - <div class="field-title">Pinned skill cap</div>
22 - <div class="field-description">
23 - Maximum number of pinned skills stored in this scope. Higher values inject more instructions into every prompt.
24 - </div>
25 - </div>
26 - <div class="field-control">
27 - <input
28 - type="number"
29 - min="1"
30 - step="1"
31 - x-model.number="config.max_active_skills"
32 - @change="applyMaxActiveSkills(config.max_active_skills, { notify: true })"
33 - >
34 - </div>
35 - </div>
36 - </template>
37 -
18 <div class="skills-toolbar">
19 <label class="skills-search">
20 <span class="material-symbols-outlined">search</span>
@@ -54,11 +34,11 @@
34 </div>
35
36 <div class="skills-section">
57 - <div class="skills-panel-title">Pinned skills</div>
37 + <div class="skills-panel-title">Loaded skills</div>
38 <div class="skills-panel-subtitle" x-text="pinnedSubtitle()"></div>
39
40 <template x-if="!loadingCatalog && selectedSkills.length === 0">
61 - <div class="skills-empty">No pinned skills yet. Pin from the list below.</div>
41 + <div class="skills-empty">No loaded skills yet.</div>
42 </template>
43
44 <div class="skills-selected-list">
@@ -78,16 +58,6 @@
58 >
59 <span class="icon material-symbols-outlined">article</span>
60 </button>
81 - <button
82 - type="button"
83 - class="button cancel icon-button"
84 - title="Remove"
85 - aria-label="Remove skill"
86 - @click="$confirmClick($event, () => removeEntry(entry))"
87 - :disabled="mutatingChat"
88 - >
89 - <span class="icon material-symbols-outlined">close</span>
90 - </button>
61 </div>
62 </div>
63 </template>
@@ -118,7 +88,7 @@
88 'is-hidden': isHidden(skill),
89 }"
90 >
121 - <label class="skills-checkbox" :title="isSelected(skill) ? 'Pinned' : 'Pin skill'">
91 + <label class="skills-checkbox" :title="isSelected(skill) ? 'Loaded in this chat' : 'Load skill into this chat'">
92 <input
93 type="checkbox"
94 :checked="isSelected(skill)"
tests/test_skills_catalog_api.py new
+163
@@ -0,0 +1,163 @@
1 +import asyncio
2 +import sys
3 +from pathlib import Path
4 +from types import SimpleNamespace
5 +
6 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
7 +if str(PROJECT_ROOT) not in sys.path:
8 + sys.path.insert(0, str(PROJECT_ROOT))
9 +
10 +from plugins._skills.api import skills_catalog
11 +
12 +
13 +class FakeHistory:
14 + def __init__(self):
15 + self.messages = []
16 +
17 + def output(self):
18 + return self.messages
19 +
20 +
21 +class FakeContext:
22 + def __init__(self):
23 + self.id = "ctx"
24 + self.data = {}
25 + self.agent = FakeAgent(self)
26 +
27 + def get_agent(self):
28 + return self.agent
29 +
30 + def get_data(self, key, recursive=True):
31 + return self.data.get(key)
32 +
33 + def set_data(self, key, value, recursive=True):
34 + self.data[key] = value
35 +
36 +
37 +class FakeAgent:
38 + def __init__(self, context):
39 + self.context = context
40 + self.history = FakeHistory()
41 + self.tool_results = []
42 +
43 + def hist_add_tool_result(self, tool_name, tool_result, **kwargs):
44 + content = {"tool_name": tool_name, "tool_result": tool_result, **kwargs}
45 + message = {"ai": False, "content": content}
46 + self.tool_results.append(content)
47 + self.history.messages.append(message)
48 + return SimpleNamespace(output=lambda: [message])
49 +
50 +
51 +def _patch_catalog(monkeypatch, context):
52 + skill = {
53 + "name": "demo-skill",
54 + "description": "Demo skill.",
55 + "path": "/a0/skills/demo-skill",
56 + "origin": "Built-in",
57 + "hidden": False,
58 + }
59 +
60 + monkeypatch.setattr(
61 + skills_catalog.AgentContext,
62 + "get",
63 + staticmethod(lambda context_id: context if context_id == context.id else None),
64 + )
65 + monkeypatch.setattr(
66 + skills_catalog.projects,
67 + "get_context_project_name",
68 + lambda _context: "",
69 + )
70 + monkeypatch.setattr(
71 + skills_catalog.skills,
72 + "list_skill_catalog",
73 + lambda *args, **kwargs: [skill],
74 + )
75 + monkeypatch.setattr(
76 + skills_catalog.skills,
77 + "load_skill_for_agent",
78 + lambda skill_name, agent: f"Skill: {skill_name}\n\nInstructions:\nUse it.",
79 + )
80 + monkeypatch.setattr(
81 + skills_catalog.skills,
82 + "add_loaded_skill_name",
83 + lambda agent, skill_name: agent.context.set_data("loaded_skills", [skill_name]),
84 + )
85 + monkeypatch.setattr(
86 + skills_catalog.skills,
87 + "get_loaded_skill_entries",
88 + lambda agent: [
89 + {"name": name} for name in (agent.context.get_data("loaded_skills") or [])
90 + ] if agent else [],
91 + )
92 + monkeypatch.setattr(skills_catalog.skills, "get_scope_active_skills", lambda agent: [])
93 + monkeypatch.setattr(skills_catalog.skills, "get_scope_hidden_skills", lambda agent: [])
94 + monkeypatch.setattr(skills_catalog.skills, "get_chat_active_skills", lambda context: [])
95 + monkeypatch.setattr(skills_catalog.skills, "get_chat_disabled_skills", lambda context: [])
96 + monkeypatch.setattr(skills_catalog.skills, "get_chat_visible_skills", lambda context: [])
97 + monkeypatch.setattr(skills_catalog.skills, "get_hidden_skills", lambda agent: [])
98 + monkeypatch.setattr(skills_catalog.skills, "get_max_active_skills", lambda **kwargs: 20)
99 +
100 + saved = []
101 + monkeypatch.setattr(skills_catalog, "save_tmp_chat", lambda ctx: saved.append(ctx.id))
102 + return saved
103 +
104 +
105 +def test_skills_catalog_activate_loads_skill_into_chat_history(monkeypatch):
106 + context = FakeContext()
107 + saved = _patch_catalog(monkeypatch, context)
108 + handler = skills_catalog.SkillsCatalog(None, None)
109 +
110 + response = asyncio.run(
111 + handler.process(
112 + {
113 + "action": "activate",
114 + "context_id": "ctx",
115 + "skill": {"name": "demo-skill", "path": "/a0/skills/demo-skill"},
116 + },
117 + None,
118 + )
119 + )
120 +
121 + assert response["ok"] is True, response
122 + assert context.get_data("loaded_skills") == ["demo-skill"]
123 + assert len(context.agent.tool_results) == 1
124 + assert "Skill: demo-skill" in context.agent.tool_results[0]["tool_result"]
125 + assert response["active_skills"][0]["state_source"] == "Loaded in chat history"
126 + assert saved == ["ctx"]
127 +
128 + duplicate = asyncio.run(
129 + handler.process(
130 + {
131 + "action": "activate",
132 + "context_id": "ctx",
133 + "skill": {"name": "demo-skill", "path": "/a0/skills/demo-skill"},
134 + },
135 + None,
136 + )
137 + )
138 +
139 + assert duplicate["ok"] is True
140 + assert len(context.agent.tool_results) == 1
141 +
142 +
143 +def test_skills_catalog_deactivate_does_not_remove_loaded_skill(monkeypatch):
144 + context = FakeContext()
145 + _patch_catalog(monkeypatch, context)
146 + context.set_data("loaded_skills", ["demo-skill"])
147 + handler = skills_catalog.SkillsCatalog(None, None)
148 +
149 + response = asyncio.run(
150 + handler.process(
151 + {
152 + "action": "deactivate",
153 + "context_id": "ctx",
154 + "skill": {"name": "demo-skill"},
155 + },
156 + None,
157 + )
158 + )
159 +
160 + assert response["ok"] is False
161 + assert "cannot be removed" in response["error"]
162 + assert context.get_data("loaded_skills") == ["demo-skill"]
163 + assert context.agent.tool_results == []
tests/test_skills_runtime.py
+29
@@ -150,6 +150,35 @@ def test_hidden_skills_are_not_capped_like_active_skills():
150 assert len(runtime.get_hidden_skills(agent)) == 25
151
152
153 +def test_active_skill_prompt_protocol_is_disabled(monkeypatch):
154 + monkeypatch.setattr(
155 + runtime.plugin_helpers,
156 + "get_plugin_config",
157 + lambda *args, **kwargs: _scope_config([{"name": "Pinned"}]),
158 + )
159 + agent = DummyAgent()
160 +
161 + assert runtime.get_active_skills(agent) == [{"name": "Pinned"}]
162 + assert runtime.build_active_skills_prompt(agent) == ""
163 +
164 +
165 +def test_hiding_skill_does_not_unload_history_loaded_skill():
166 + agent = DummyAgent()
167 + agent.context.set_data(
168 + runtime.CONTEXT_DATA_NAME_LOADED_SKILLS,
169 + ["history-skill"],
170 + )
171 +
172 + runtime.hide_chat_skill(agent, {"name": "history-skill"})
173 +
174 + assert agent.context.get_data(runtime.CONTEXT_DATA_NAME_LOADED_SKILLS) == [
175 + "history-skill"
176 + ]
177 + assert runtime.get_chat_disabled_skills(agent.context) == [
178 + {"name": "history-skill"}
179 + ]
180 +
181 +
182 def test_chat_activation_can_override_scope_defaults(monkeypatch):
183 monkeypatch.setattr(
184 runtime.plugin_helpers,