From dea64ddad083d828aede004d42e926f4e4fd1015 Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Wed, 8 Jul 2026 01:52:22 +0200 Subject: [PATCH] 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. --- .../message_loop_prompts_after/AGENTS.md | 2 +- helpers/skills.py | 4 +- helpers/skills.py.dox.md | 2 + plugins/_skills/AGENTS.md | 15 +- plugins/_skills/README.md | 23 ++- plugins/_skills/api/skills_catalog.py | 93 ++++++---- plugins/_skills/plugin.yaml | 2 +- .../prompts/agent.system.active_skills.md | 2 +- plugins/_skills/webui/config-store.js | 139 ++------------- plugins/_skills/webui/config.html | 36 +--- tests/test_skills_catalog_api.py | 163 ++++++++++++++++++ tests/test_skills_runtime.py | 29 ++++ 12 files changed, 293 insertions(+), 217 deletions(-) create mode 100644 tests/test_skills_catalog_api.py diff --git a/extensions/python/message_loop_prompts_after/AGENTS.md b/extensions/python/message_loop_prompts_after/AGENTS.md index ba8118a2f..cc80e0f34 100644 --- a/extensions/python/message_loop_prompts_after/AGENTS.md +++ b/extensions/python/message_loop_prompts_after/AGENTS.md @@ -7,9 +7,9 @@ ## Ownership - Ordered Python files own current datetime, skill recall/load context, agent info, parallel job status, and workdir extras injection. -- Active skill instructions belong in prompt protocol. - Explicitly loaded skill bodies belong in tool-result history with metadata so they can survive persistence and be reattached after compaction. - Explicitly loaded skill IDs are chat-wide context data, not agent-local state. +- Legacy active-skill prompt protocol injection must stay empty; selected skills are loaded through history. ## Local Contracts diff --git a/helpers/skills.py b/helpers/skills.py index 74488da9d..2e5d5c90a 100644 --- a/helpers/skills.py +++ b/helpers/skills.py @@ -1132,7 +1132,6 @@ def hide_chat_skill(agent: Agent, entry: Any) -> list[ActiveSkillEntry]: CONTEXT_DATA_NAME_CHAT_VISIBLE_SKILLS, visible_entries, ) - unload_agent_skill(agent, normalized) return get_hidden_skills(agent) @@ -1183,8 +1182,7 @@ def clear_chat_skill_overrides(agent: Agent) -> list[ActiveSkillEntry]: def build_active_skills_prompt(agent: Agent | None) -> str: - items = _resolve_active_skill_entries(agent, get_active_skills(agent)) - return "\n\n".join(item["content"] for item in items if item.get("content")).strip() + return "" def _format_skill_prompt(skill: Skill) -> str: diff --git a/helpers/skills.py.dox.md b/helpers/skills.py.dox.md index d269bb205..073997044 100644 --- a/helpers/skills.py.dox.md +++ b/helpers/skills.py.dox.md @@ -56,6 +56,8 @@ - Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together. - Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change. - 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. +- Loaded skill bodies live in chat history; hiding a skill changes catalog visibility but does not remove the loaded-skill ledger. +- `build_active_skills_prompt()` returns empty because selected skills are loaded through history, not prompt protocol. - 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. - Observed side-effect areas: filesystem reads, filesystem deletion, plugin state, settings/state persistence, context data, secret handling. - Imported dependency areas include: `__future__`, `dataclasses`, `helpers`, `os`, `pathlib`, `re`, `typing`. diff --git a/plugins/_skills/AGENTS.md b/plugins/_skills/AGENTS.md index ebb1af367..a1ca0d81e 100644 --- a/plugins/_skills/AGENTS.md +++ b/plugins/_skills/AGENTS.md @@ -2,25 +2,26 @@ ## Purpose -- Own active and hidden skill configuration injected into prompt protocol on each turn. +- Own current-chat skill loading and hidden skill configuration. ## Ownership -- `hooks.py` owns skill prompt injection and plugin lifecycle behavior. -- `api/skills_catalog.py` owns skill catalog access. -- `prompts/agent.system.active_skills.md` owns injected active-skill prompt content. +- `hooks.py` owns skill config normalization. +- `api/skills_catalog.py` owns skill catalog access and loading selected skills into chat history. +- `prompts/agent.system.active_skills.md` is retained only for legacy prompt-protocol compatibility. - `webui/` owns skill settings UI and store. - `default_config.yaml`, `plugin.yaml`, `README.md`, and `LICENSE` own defaults, metadata, docs, and license. ## Local Contracts -- Keep active skill lists bounded by configured caps. +- Skills selected in `webui/` load into the current chat history only; do not store them as scope defaults. +- Loaded skills are append-only from the user UI because their instructions live in chat history. - Store configured skills in normalized portable paths. -- Hidden skills affect catalog/search/load visibility but must not be injected as active prompt content. +- Hidden skills affect catalog/search/load visibility but must not remove loaded skill history. ## Work Guidance -- Coordinate active-skill resolution changes with core skill loading and settings UI. +- Coordinate skill loading changes with `skills_tool`, loaded-skill history reattachment, and settings UI. ## Verification diff --git a/plugins/_skills/README.md b/plugins/_skills/README.md index 4d27c4f96..66ecbab2a 100644 --- a/plugins/_skills/README.md +++ b/plugins/_skills/README.md @@ -1,29 +1,28 @@ # Skills -Skills is a built-in Agent Zero plugin that manages active skills across scope defaults and the current chat. +Skills is a built-in Agent Zero plugin that manages skill loading and visibility for the current chat. ## What It Does -- pins default skills for the current plugin scope +- loads selected skills into current-chat history - hides noisy skills from the model-facing available catalog, skill search, and load access -- injects the effective active skills into prompt protocol on every turn -- extends the same config screen with a current-chat mode so users can activate or hide skills live per conversation +- shows loaded skills without offering removal, because loaded skill bodies are part of chat history +- lets users hide or show skills live per conversation - supports global and project scoped configurations without agent-profile variants - links directly to the built-in Skills list - links directly to the active project's Skills section when a project is active ## Why This Exists -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. +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. Skills fills that gap as a bundled built-in plugin. -The shared active-skill state and prompt-resolution logic live in `helpers/skills.py`, and this plugin focuses on configuration, UI, and prompt injection. +The shared skill discovery and loaded-skill ledger live in `helpers/skills.py`, and this plugin focuses on catalog UI, chat loading, and visibility. ## Notes -- keep the active list short because every active skill is injected into prompt protocol every turn -- the default cap is 20 active skills, and it can be raised or lowered in Skills plugin config -- hidden skills are not capped because they are stored as control data, not injected into the prompt -- selected skills are stored in normalized `/a0/...` form so configs stay portable across development and Docker-style layouts -- scope defaults can be hidden or supplemented per chat without creating a new conversation -- if a configured skill is not visible in the current agent scope, it is skipped quietly instead of breaking the prompt build +- selected skills are appended to current-chat history using the same metadata shape as `skills_tool` +- loaded skills are not removable from the UI; future context compaction may reattach their bodies from the loaded-skill ledger +- hidden skills are stored as control data, not injected into the prompt +- hidden skill paths are stored in normalized `/a0/...` form so configs stay portable across development and Docker-style layouts +- if a configured hidden skill is not visible in the current agent scope, it is skipped quietly instead of breaking catalog builds diff --git a/plugins/_skills/api/skills_catalog.py b/plugins/_skills/api/skills_catalog.py index 38ec23bb5..104de3332 100644 --- a/plugins/_skills/api/skills_catalog.py +++ b/plugins/_skills/api/skills_catalog.py @@ -37,17 +37,36 @@ class SkillsCatalog(ApiHandler): def _activate(self, input: dict, *, context_id: str) -> dict[str, Any]: context = self._require_context(context_id) skill_entry = self._require_skill_entry(input) - skills.activate_chat_skill(context.get_agent(), skill_entry) + agent = context.get_agent() + skill = self._resolve_catalog_skill(skill_entry, context=context) + skill_name = str(skill.get("name") or skill_entry.get("name") or "").strip() + if not skill_name: + raise ValueError("Skill name is required") + + skill_path = str(skill.get("path") or skill_entry.get("path") or "").strip() + skills.add_loaded_skill_name(agent, skill_name) + if not self._visible_skill_loaded(agent, skill_name): + content = skills.load_skill_for_agent(skill_name=skill_name, agent=agent) + if content.startswith("Error:"): + raise ValueError(content) + agent.hist_add_tool_result( + "skills_tool", + content, + skill_instructions={ + "name": skill_name, + "path": skill_path, + "source": "skills_page:load", + "content_included": True, + }, + ) save_tmp_chat(context) return self._build_state(context_id=context.id) def _deactivate(self, input: dict, *, context_id: str) -> dict[str, Any]: - context = self._require_context(context_id) - skill_entry = self._require_skill_entry(input) - skills.deactivate_chat_skill(context.get_agent(), skill_entry) - skills.unload_agent_skill(context.get_agent(), skill_entry) - save_tmp_chat(context) - return self._build_state(context_id=context.id) + return { + "ok": False, + "error": "Loaded skills are kept in chat history and cannot be removed.", + } def _hide(self, input: dict, *, context_id: str) -> dict[str, Any]: context = self._require_context(context_id) @@ -64,10 +83,10 @@ class SkillsCatalog(ApiHandler): return self._build_state(context_id=context.id) def _clear(self, *, context_id: str) -> dict[str, Any]: - context = self._require_context(context_id) - skills.clear_chat_skill_overrides(context.get_agent()) - save_tmp_chat(context) - return self._build_state(context_id=context.id) + return { + "ok": False, + "error": "Loaded skills are kept in chat history and cannot be removed.", + } def _build_state( self, @@ -87,26 +106,13 @@ class SkillsCatalog(ApiHandler): str(skill.get("name") or "").strip().lower(): skill for skill in catalog } + loaded_entries = skills.get_loaded_skill_entries(agent) scope_entries = skills.get_scope_active_skills(agent) scope_hidden_entries = skills.get_scope_hidden_skills(agent) chat_entries = skills.get_chat_active_skills(context) disabled_entries = skills.get_chat_disabled_skills(context) visible_entries = skills.get_chat_visible_skills(context) hidden_entries = skills.get_hidden_skills(agent) - active_entries = self._merge_entries( - skills.get_active_skills(agent), - self._filter_hidden_entries( - self._get_loaded_skill_entries(agent), - hidden_entries, - ), - ) - - scope_keys = { - self._entry_key(entry) for entry in scope_entries if self._entry_key(entry) - } - chat_keys = { - self._entry_key(entry) for entry in chat_entries if self._entry_key(entry) - } return { "ok": True, @@ -123,15 +129,9 @@ class SkillsCatalog(ApiHandler): entry, catalog_by_key, catalog_by_name, - state_source=( - "Pinned + chat" - if key in scope_keys and key in chat_keys - else "Pinned default" - if key in scope_keys - else "Chat" - ), + state_source="Loaded in chat history", ) - for entry in active_entries + for entry in loaded_entries if (key := self._entry_key(entry)) ], "scope_skills": [ @@ -139,7 +139,7 @@ class SkillsCatalog(ApiHandler): entry, catalog_by_key, catalog_by_name, - state_source="Pinned default", + state_source="Scope default", ) for entry in scope_entries ], @@ -253,11 +253,28 @@ class SkillsCatalog(ApiHandler): def _entry_key(self, entry: dict[str, Any]) -> str: return str(entry.get("path") or entry.get("name") or "").strip().lower() - def _get_loaded_skill_entries(self, agent: Any | None) -> list[dict[str, str]]: - if not agent: - return [] + def _visible_skill_loaded(self, agent: Any, skill_name: str) -> bool: + output = getattr(getattr(agent, "history", None), "output", None) + if not callable(output): + return False + return any( + skills.skill_instruction_name(message) == skill_name + for message in output() + ) - return skills.get_loaded_skill_entries(agent) + def _resolve_catalog_skill( + self, + entry: dict[str, Any], + *, + context: AgentContext, + ) -> dict[str, Any]: + agent = context.get_agent() + project_name = projects.get_context_project_name(context) or "" + catalog = skills.list_skill_catalog(project_name=project_name, agent=agent) + return next( + (item for item in catalog if self._entry_matches_any(entry, [item])), + entry, + ) def _merge_entries( self, diff --git a/plugins/_skills/plugin.yaml b/plugins/_skills/plugin.yaml index 920d57b6c..f5361e385 100644 --- a/plugins/_skills/plugin.yaml +++ b/plugins/_skills/plugin.yaml @@ -1,6 +1,6 @@ name: _skills title: Skills -description: Pin skills into prompt protocol on every turn. +description: Load skills into the current chat history. version: 1.0.0 always_enabled: true settings_sections: diff --git a/plugins/_skills/prompts/agent.system.active_skills.md b/plugins/_skills/prompts/agent.system.active_skills.md index a90a62075..919d8145b 100644 --- a/plugins/_skills/prompts/agent.system.active_skills.md +++ b/plugins/_skills/prompts/agent.system.active_skills.md @@ -1,5 +1,5 @@ ## active skills -The following skills were manually activated by the User. +The following skills were explicitly activated for this chat. Treat them as already loaded instructions and follow them when relevant. {{skills}} diff --git a/plugins/_skills/webui/config-store.js b/plugins/_skills/webui/config-store.js index 12ce77e69..f90b895fa 100644 --- a/plugins/_skills/webui/config-store.js +++ b/plugins/_skills/webui/config-store.js @@ -1,27 +1,9 @@ import * as API from "/js/api.js"; import { store as markdownModalStore } from "/components/modals/markdown/markdown-store.js"; import { store as chatsStore } from "/components/sidebar/chats/chats-store.js"; -import { - toastFrontendError, - toastFrontendInfo, -} from "/components/notifications/notification-store.js"; +import { toastFrontendError } from "/components/notifications/notification-store.js"; const CATALOG_API = "/plugins/_skills/skills_catalog"; -const MAX_ACTIVE_SKILLS_FALLBACK = 20; - -function normalizeMaxActiveSkills(value) { - if (typeof value === "boolean") return MAX_ACTIVE_SKILLS_FALLBACK; - - const numeric = typeof value === "number" - ? value - : Number.parseInt(String(value ?? "").trim(), 10); - - if (!Number.isFinite(numeric) || numeric < 1) { - return MAX_ACTIVE_SKILLS_FALLBACK; - } - - return Math.floor(numeric); -} function normalizeEntry(entry) { if (!entry) return null; @@ -62,11 +44,8 @@ function entriesMatch(left, right) { function ensureConfig(config) { if (!config || typeof config !== "object") return; - config.max_active_skills = normalizeMaxActiveSkills(config.max_active_skills); - const activeSkills = Array.isArray(config.active_skills) ? config.active_skills : []; const hiddenSkills = Array.isArray(config.hidden_skills) ? config.hidden_skills : []; - config.active_skills = compactEntries(activeSkills, config.max_active_skills); config.hidden_skills = compactEntries(hiddenSkills); } @@ -94,15 +73,13 @@ window.createSkillsConfigModel = (context, config) => ({ mutatingChat: false, catalog: [], search: "", - maxActiveSkills: MAX_ACTIVE_SKILLS_FALLBACK, selectedSkills: [], hiddenSkills: [], chatContextAvailable: false, initDefaults() { ensureConfig(config); - this.maxActiveSkills = config.max_active_skills; - this.selectedSkills = [...this.activeEntries]; + this.selectedSkills = []; this.hiddenSkills = [...this.hiddenEntries]; }, @@ -110,50 +87,11 @@ window.createSkillsConfigModel = (context, config) => ({ return context?.openOptions?.focus === "chat"; }, - get activeEntries() { - ensureConfig(config); - return config.active_skills; - }, - get hiddenEntries() { ensureConfig(config); return config.hidden_skills; }, - get selectedCount() { - return this.selectedSkills.length; - }, - - async applyMaxActiveSkills(value, { notify = false } = {}) { - ensureConfig(config); - - const nextLimit = normalizeMaxActiveSkills(value); - const previousLimit = this.maxActiveSkills; - const previousCount = this.selectedSkills.length; - - config.max_active_skills = nextLimit; - this.maxActiveSkills = nextLimit; - - if (previousCount > nextLimit) { - this._setSelectedSkills(this.selectedSkills.slice(0, nextLimit)); - - if (notify) { - await toastFrontendInfo( - `Trimmed ${previousCount - nextLimit} pinned skill${previousCount - nextLimit === 1 ? "" : "s"} to match the new cap of ${nextLimit}.`, - "Skills" - ); - } - return; - } - - if (notify && previousLimit !== nextLimit) { - await toastFrontendInfo( - `Pinned skill cap set to ${nextLimit}.`, - "Skills" - ); - } - }, - get catalogMap() { const byKey = new Map(); for (const skill of this.catalog) { @@ -188,15 +126,13 @@ window.createSkillsConfigModel = (context, config) => ({ }, pinnedSubtitle() { - return this.isChatMode - ? "These skills are currently pinned into the prompt for this chat." - : "These skills are pinned by default in this scope."; + return "These skills are loaded into the current chat history."; }, allSkillsSubtitle() { return this.isChatMode - ? "Check a skill to pin it for this chat. Use the eye control to hide or show it in this chat." - : "Check a skill to pin it by default. Use the eye control to hide or show it in this scope."; + ? "Check a skill to load it into this chat. Use the eye control to hide or show it in this chat." + : "Check a skill to load it into the current chat. Use the eye control to hide or show it in this scope."; }, hiddenStateLabel() { @@ -223,7 +159,7 @@ window.createSkillsConfigModel = (context, config) => ({ }, isPinDisabled(skill) { - return this.mutatingChat || (!this.isSelected(skill) && this.selectedCount >= this.maxActiveSkills); + return this.mutatingChat || !this.chatContextAvailable || this.isSelected(skill); }, isVisibilityDisabled() { @@ -260,7 +196,7 @@ window.createSkillsConfigModel = (context, config) => ({ return name ? this.catalogMap.get(name) || null : null; }, - _setSelectedSkills(entries, { writeConfig = true } = {}) { + _setSelectedSkills(entries) { const normalized = []; const seen = new Set(); @@ -270,13 +206,9 @@ window.createSkillsConfigModel = (context, config) => ({ if (!item || !key || seen.has(key)) continue; seen.add(key); normalized.push(item); - if (normalized.length >= this.maxActiveSkills) break; } this.selectedSkills = normalized; - if (writeConfig) { - config.active_skills = compactEntries(normalized, this.maxActiveSkills); - } }, _setHiddenSkills(entries, { writeConfig = true } = {}) { @@ -313,54 +245,28 @@ window.createSkillsConfigModel = (context, config) => ({ }, async togglePinnedSkill(skill, selected) { - const nextEntries = this.selectedSkills.filter((entry) => !entriesMatch(entry, skill)); - - if (selected) { - if (this.selectedCount >= this.maxActiveSkills && !this.isSelected(skill)) { - await toastFrontendInfo( - `You can activate at most ${this.maxActiveSkills} skills.`, - "Skills" - ); - return; - } - - nextEntries.push({ - name: String(skill.name || "").trim(), - path: String(skill.path || "").trim(), - }); + if (!selected || this.isSelected(skill)) { + await this.loadCatalog(); + return; } - this._setSelectedSkills(nextEntries, { writeConfig: !this.isChatMode }); - - if (this.isChatMode && this.chatContextAvailable) { - await this.submitChatAction(selected ? "activate" : "deactivate", skill); + if (!this.chatContextAvailable) { + await toastFrontendError("Open a chat before loading a skill.", "Skills"); + await this.loadCatalog(); + return; } - }, - async removeEntry(entry) { - await this.togglePinnedSkill(entry, false); - }, - - async clearSelections() { - const previous = [...this.selectedSkills]; - this._setSelectedSkills([], { writeConfig: !this.isChatMode }); - if (this.isChatMode && this.chatContextAvailable) { - for (const entry of previous) { - await this.submitChatAction("deactivate", entry); - } - } + await this.submitChatAction("activate", skill); }, applyCatalogState(response) { this.chatContextAvailable = !!response?.context_available; const activeFromChat = Array.isArray(response?.active_skills) ? response.active_skills : null; - const activeFromConfig = this.activeEntries; const hiddenFromChat = Array.isArray(response?.hidden_skills) ? response.hidden_skills : null; const hiddenFromConfig = this.hiddenEntries; this._setSelectedSkills( - this.isChatMode ? activeFromChat || [] : activeFromConfig, - { writeConfig: !this.isChatMode }, + activeFromChat || [], ); this._setHiddenSkills( this.isChatMode ? hiddenFromChat || [] : hiddenFromConfig, @@ -374,7 +280,7 @@ window.createSkillsConfigModel = (context, config) => ({ const response = await API.callJsonApi(CATALOG_API, { action: "list", project_name: context.projectName || "", - context_id: this.isChatMode ? chatsStore.selectedContext?.id || "" : "", + context_id: chatsStore.selectedContext?.id || "", }); if (!response?.ok) { @@ -382,17 +288,12 @@ window.createSkillsConfigModel = (context, config) => ({ } this.catalog = Array.isArray(response.skills) ? response.skills : []; - this.maxActiveSkills = normalizeMaxActiveSkills(response.max_active_skills); - if (!this.isChatMode) { - config.max_active_skills = this.maxActiveSkills; - } this.applyCatalogState(response); } catch (error) { this.catalog = []; ensureConfig(config); - this.maxActiveSkills = config.max_active_skills; this.chatContextAvailable = false; - this._setSelectedSkills(this.activeEntries); + this._setSelectedSkills([]); this._setHiddenSkills(this.hiddenEntries); await toastFrontendError(error?.message || "Failed to load skills", "Skills"); } finally { @@ -422,10 +323,6 @@ window.createSkillsConfigModel = (context, config) => ({ } this.catalog = Array.isArray(response.skills) ? response.skills : this.catalog; - this.maxActiveSkills = normalizeMaxActiveSkills(response.max_active_skills); - if (!this.isChatMode) { - config.max_active_skills = this.maxActiveSkills; - } this.chatContextAvailable = !!response.context_available; this.applyCatalogState(response); return true; diff --git a/plugins/_skills/webui/config.html b/plugins/_skills/webui/config.html index 7885368ae..6965c0e18 100644 --- a/plugins/_skills/webui/config.html +++ b/plugins/_skills/webui/config.html @@ -15,26 +15,6 @@
Skills
- -
-
Pinned skills
+
Loaded skills
@@ -78,16 +58,6 @@ > article -
@@ -118,7 +88,7 @@ 'is-hidden': isHidden(skill), }" > -