mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-07-10 01:18:29 +00:00
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.
This commit is contained in:
parent
5ac4f75a47
commit
dea64ddad0
12 changed files with 293 additions and 217 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -15,26 +15,6 @@
|
|||
<div class="skills-layout">
|
||||
<div class="section-title">Skills</div>
|
||||
|
||||
<template x-if="!isChatMode">
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<div class="field-title">Pinned skill cap</div>
|
||||
<div class="field-description">
|
||||
Maximum number of pinned skills stored in this scope. Higher values inject more instructions into every prompt.
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-control">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
x-model.number="config.max_active_skills"
|
||||
@change="applyMaxActiveSkills(config.max_active_skills, { notify: true })"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="skills-toolbar">
|
||||
<label class="skills-search">
|
||||
<span class="material-symbols-outlined">search</span>
|
||||
|
|
@ -54,11 +34,11 @@
|
|||
</div>
|
||||
|
||||
<div class="skills-section">
|
||||
<div class="skills-panel-title">Pinned skills</div>
|
||||
<div class="skills-panel-title">Loaded skills</div>
|
||||
<div class="skills-panel-subtitle" x-text="pinnedSubtitle()"></div>
|
||||
|
||||
<template x-if="!loadingCatalog && selectedSkills.length === 0">
|
||||
<div class="skills-empty">No pinned skills yet. Pin from the list below.</div>
|
||||
<div class="skills-empty">No loaded skills yet.</div>
|
||||
</template>
|
||||
|
||||
<div class="skills-selected-list">
|
||||
|
|
@ -78,16 +58,6 @@
|
|||
>
|
||||
<span class="icon material-symbols-outlined">article</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button cancel icon-button"
|
||||
title="Remove"
|
||||
aria-label="Remove skill"
|
||||
@click="$confirmClick($event, () => removeEntry(entry))"
|
||||
:disabled="mutatingChat"
|
||||
>
|
||||
<span class="icon material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -118,7 +88,7 @@
|
|||
'is-hidden': isHidden(skill),
|
||||
}"
|
||||
>
|
||||
<label class="skills-checkbox" :title="isSelected(skill) ? 'Pinned' : 'Pin skill'">
|
||||
<label class="skills-checkbox" :title="isSelected(skill) ? 'Loaded in this chat' : 'Load skill into this chat'">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isSelected(skill)"
|
||||
|
|
|
|||
163
tests/test_skills_catalog_api.py
Normal file
163
tests/test_skills_catalog_api.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from plugins._skills.api import skills_catalog
|
||||
|
||||
|
||||
class FakeHistory:
|
||||
def __init__(self):
|
||||
self.messages = []
|
||||
|
||||
def output(self):
|
||||
return self.messages
|
||||
|
||||
|
||||
class FakeContext:
|
||||
def __init__(self):
|
||||
self.id = "ctx"
|
||||
self.data = {}
|
||||
self.agent = FakeAgent(self)
|
||||
|
||||
def get_agent(self):
|
||||
return self.agent
|
||||
|
||||
def get_data(self, key, recursive=True):
|
||||
return self.data.get(key)
|
||||
|
||||
def set_data(self, key, value, recursive=True):
|
||||
self.data[key] = value
|
||||
|
||||
|
||||
class FakeAgent:
|
||||
def __init__(self, context):
|
||||
self.context = context
|
||||
self.history = FakeHistory()
|
||||
self.tool_results = []
|
||||
|
||||
def hist_add_tool_result(self, tool_name, tool_result, **kwargs):
|
||||
content = {"tool_name": tool_name, "tool_result": tool_result, **kwargs}
|
||||
message = {"ai": False, "content": content}
|
||||
self.tool_results.append(content)
|
||||
self.history.messages.append(message)
|
||||
return SimpleNamespace(output=lambda: [message])
|
||||
|
||||
|
||||
def _patch_catalog(monkeypatch, context):
|
||||
skill = {
|
||||
"name": "demo-skill",
|
||||
"description": "Demo skill.",
|
||||
"path": "/a0/skills/demo-skill",
|
||||
"origin": "Built-in",
|
||||
"hidden": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
skills_catalog.AgentContext,
|
||||
"get",
|
||||
staticmethod(lambda context_id: context if context_id == context.id else None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
skills_catalog.projects,
|
||||
"get_context_project_name",
|
||||
lambda _context: "",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
skills_catalog.skills,
|
||||
"list_skill_catalog",
|
||||
lambda *args, **kwargs: [skill],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
skills_catalog.skills,
|
||||
"load_skill_for_agent",
|
||||
lambda skill_name, agent: f"Skill: {skill_name}\n\nInstructions:\nUse it.",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
skills_catalog.skills,
|
||||
"add_loaded_skill_name",
|
||||
lambda agent, skill_name: agent.context.set_data("loaded_skills", [skill_name]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
skills_catalog.skills,
|
||||
"get_loaded_skill_entries",
|
||||
lambda agent: [
|
||||
{"name": name} for name in (agent.context.get_data("loaded_skills") or [])
|
||||
] if agent else [],
|
||||
)
|
||||
monkeypatch.setattr(skills_catalog.skills, "get_scope_active_skills", lambda agent: [])
|
||||
monkeypatch.setattr(skills_catalog.skills, "get_scope_hidden_skills", lambda agent: [])
|
||||
monkeypatch.setattr(skills_catalog.skills, "get_chat_active_skills", lambda context: [])
|
||||
monkeypatch.setattr(skills_catalog.skills, "get_chat_disabled_skills", lambda context: [])
|
||||
monkeypatch.setattr(skills_catalog.skills, "get_chat_visible_skills", lambda context: [])
|
||||
monkeypatch.setattr(skills_catalog.skills, "get_hidden_skills", lambda agent: [])
|
||||
monkeypatch.setattr(skills_catalog.skills, "get_max_active_skills", lambda **kwargs: 20)
|
||||
|
||||
saved = []
|
||||
monkeypatch.setattr(skills_catalog, "save_tmp_chat", lambda ctx: saved.append(ctx.id))
|
||||
return saved
|
||||
|
||||
|
||||
def test_skills_catalog_activate_loads_skill_into_chat_history(monkeypatch):
|
||||
context = FakeContext()
|
||||
saved = _patch_catalog(monkeypatch, context)
|
||||
handler = skills_catalog.SkillsCatalog(None, None)
|
||||
|
||||
response = asyncio.run(
|
||||
handler.process(
|
||||
{
|
||||
"action": "activate",
|
||||
"context_id": "ctx",
|
||||
"skill": {"name": "demo-skill", "path": "/a0/skills/demo-skill"},
|
||||
},
|
||||
None,
|
||||
)
|
||||
)
|
||||
|
||||
assert response["ok"] is True, response
|
||||
assert context.get_data("loaded_skills") == ["demo-skill"]
|
||||
assert len(context.agent.tool_results) == 1
|
||||
assert "Skill: demo-skill" in context.agent.tool_results[0]["tool_result"]
|
||||
assert response["active_skills"][0]["state_source"] == "Loaded in chat history"
|
||||
assert saved == ["ctx"]
|
||||
|
||||
duplicate = asyncio.run(
|
||||
handler.process(
|
||||
{
|
||||
"action": "activate",
|
||||
"context_id": "ctx",
|
||||
"skill": {"name": "demo-skill", "path": "/a0/skills/demo-skill"},
|
||||
},
|
||||
None,
|
||||
)
|
||||
)
|
||||
|
||||
assert duplicate["ok"] is True
|
||||
assert len(context.agent.tool_results) == 1
|
||||
|
||||
|
||||
def test_skills_catalog_deactivate_does_not_remove_loaded_skill(monkeypatch):
|
||||
context = FakeContext()
|
||||
_patch_catalog(monkeypatch, context)
|
||||
context.set_data("loaded_skills", ["demo-skill"])
|
||||
handler = skills_catalog.SkillsCatalog(None, None)
|
||||
|
||||
response = asyncio.run(
|
||||
handler.process(
|
||||
{
|
||||
"action": "deactivate",
|
||||
"context_id": "ctx",
|
||||
"skill": {"name": "demo-skill"},
|
||||
},
|
||||
None,
|
||||
)
|
||||
)
|
||||
|
||||
assert response["ok"] is False
|
||||
assert "cannot be removed" in response["error"]
|
||||
assert context.get_data("loaded_skills") == ["demo-skill"]
|
||||
assert context.agent.tool_results == []
|
||||
|
|
@ -150,6 +150,35 @@ def test_hidden_skills_are_not_capped_like_active_skills():
|
|||
assert len(runtime.get_hidden_skills(agent)) == 25
|
||||
|
||||
|
||||
def test_active_skill_prompt_protocol_is_disabled(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
runtime.plugin_helpers,
|
||||
"get_plugin_config",
|
||||
lambda *args, **kwargs: _scope_config([{"name": "Pinned"}]),
|
||||
)
|
||||
agent = DummyAgent()
|
||||
|
||||
assert runtime.get_active_skills(agent) == [{"name": "Pinned"}]
|
||||
assert runtime.build_active_skills_prompt(agent) == ""
|
||||
|
||||
|
||||
def test_hiding_skill_does_not_unload_history_loaded_skill():
|
||||
agent = DummyAgent()
|
||||
agent.context.set_data(
|
||||
runtime.CONTEXT_DATA_NAME_LOADED_SKILLS,
|
||||
["history-skill"],
|
||||
)
|
||||
|
||||
runtime.hide_chat_skill(agent, {"name": "history-skill"})
|
||||
|
||||
assert agent.context.get_data(runtime.CONTEXT_DATA_NAME_LOADED_SKILLS) == [
|
||||
"history-skill"
|
||||
]
|
||||
assert runtime.get_chat_disabled_skills(agent.context) == [
|
||||
{"name": "history-skill"}
|
||||
]
|
||||
|
||||
|
||||
def test_chat_activation_can_override_scope_defaults(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
runtime.plugin_helpers,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue