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,