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:
Alessandro 2026-07-08 01:52:22 +02:00
parent 5ac4f75a47
commit dea64ddad0
12 changed files with 293 additions and 217 deletions

View file

@ -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

View file

@ -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:

View file

@ -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`.

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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:

View file

@ -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}}

View file

@ -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;

View file

@ -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)"

View 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 == []

View file

@ -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,