Make skills cap configurable

- add max_active_skills to the _skills plugin config and expose it in the config UI - enforce the scoped cap consistently in skills runtime, catalog responses, and chat activation - cover raised and lowered cap behavior with focused skills runtime tests

Alessandro committed May 26, 2026 at 17:01 UTC b337ba3db86d6a7d0ed87e37277494a001e0a2e3
7 files changed +204 -20
helpers/skills.py
+63 -12
@@ -581,14 +581,46 @@ def validate_skill_md(skill_md_path: Path) -> List[str]:
581 return validate_skill(skill)
582
583
584 -def get_max_active_skills() -> int:
585 - return MAX_ACTIVE_SKILLS
584 +def _normalize_max_active_skills(value: Any) -> int:
585 + if isinstance(value, bool):
586 + return MAX_ACTIVE_SKILLS
587 +
588 + try:
589 + normalized = int(value)
590 + except (TypeError, ValueError):
591 + return MAX_ACTIVE_SKILLS
592 +
593 + return normalized if normalized >= 1 else MAX_ACTIVE_SKILLS
594 +
595 +
596 +def get_max_active_skills(
597 + agent: Agent | None = None,
598 + project_name: str | None = None,
599 +) -> int:
600 + if agent is None and project_name is None:
601 + return MAX_ACTIVE_SKILLS
602 +
603 + config = (
604 + plugin_helpers.get_plugin_config(
605 + ACTIVE_SKILLS_PLUGIN_NAME,
606 + agent=agent,
607 + project_name=project_name or "",
608 + agent_profile="",
609 + )
610 + or {}
611 + )
612 + return _normalize_max_active_skills(config.get("max_active_skills"))
613
614
615 def normalize_skills_config(config: dict[str, Any] | None) -> dict[str, Any]:
616 normalized = dict(config or {})
617 + max_active_skills = _normalize_max_active_skills(
618 + normalized.get("max_active_skills")
619 + )
620 + normalized["max_active_skills"] = max_active_skills
621 normalized["active_skills"] = normalize_active_skills(
591 - normalized.get("active_skills")
622 + normalized.get("active_skills"),
623 + limit=max_active_skills,
624 )
625 normalized["hidden_skills"] = normalize_hidden_skills(
626 normalized.get("hidden_skills")
@@ -596,8 +628,15 @@ def normalize_skills_config(config: dict[str, Any] | None) -> dict[str, Any]:
628 return normalized
629
630
599 -def normalize_active_skills(raw: Any) -> list[ActiveSkillEntry]:
600 - return normalize_skill_entries(raw, limit=get_max_active_skills())
631 +def normalize_active_skills(
632 + raw: Any,
633 + *,
634 + limit: int | None = None,
635 +) -> list[ActiveSkillEntry]:
636 + return normalize_skill_entries(
637 + raw,
638 + limit=get_max_active_skills() if limit is None else limit,
639 + )
640
641
642 def normalize_hidden_skills(raw: Any) -> list[ActiveSkillEntry]:
@@ -686,7 +725,10 @@ def get_scope_active_skills(agent: Agent | None) -> list[ActiveSkillEntry]:
725 )
726 or {}
727 )
689 - return normalize_active_skills(config.get("active_skills"))
728 + return normalize_active_skills(
729 + config.get("active_skills"),
730 + limit=get_max_active_skills(agent=agent, project_name=project_name),
731 + )
732
733
734 def get_scope_hidden_skills(agent: Agent | None) -> list[ActiveSkillEntry]:
@@ -709,7 +751,11 @@ def get_scope_hidden_skills(agent: Agent | None) -> list[ActiveSkillEntry]:
751 def get_chat_active_skills(context: Any | None) -> list[ActiveSkillEntry]:
752 if not context:
753 return []
712 - return normalize_active_skills(context.get_data(CONTEXT_DATA_NAME_CHAT_ACTIVE_SKILLS))
754 + agent = context.get_agent() if hasattr(context, "get_agent") else None
755 + return normalize_active_skills(
756 + context.get_data(CONTEXT_DATA_NAME_CHAT_ACTIVE_SKILLS),
757 + limit=get_max_active_skills(agent=agent),
758 + )
759
760
761 def get_chat_disabled_skills(context: Any | None) -> list[ActiveSkillEntry]:
@@ -752,7 +798,7 @@ def _build_active_skills(
798 return []
799
800 context = getattr(agent, "context", None)
755 - effective_limit = get_max_active_skills() if limit is None else limit
801 + effective_limit = get_max_active_skills(agent=agent) if limit is None else limit
802 scope_entries = get_scope_active_skills(agent)
803 current_chat_entries = list(
804 chat_entries if chat_entries is not None else get_chat_active_skills(context)
@@ -781,7 +827,7 @@ def _build_active_skills(
827
828
829 def get_active_skills(agent: Agent | None) -> list[ActiveSkillEntry]:
784 - return _build_active_skills(agent, limit=get_max_active_skills())
830 + return _build_active_skills(agent, limit=get_max_active_skills(agent=agent))
831
832
833 def get_loaded_skill_entries(agent: Agent | None) -> list[ActiveSkillEntry]:
@@ -864,9 +910,10 @@ def activate_chat_skill(agent: Agent, entry: Any) -> list[ActiveSkillEntry]:
910 visible_entries=visible_entries,
911 limit=-1,
912 )
867 - if len(merged_entries) > get_max_active_skills():
913 + max_active_skills = get_max_active_skills(agent=agent)
914 + if len(merged_entries) > max_active_skills:
915 raise ValueError(
869 - f"You can activate at most {get_max_active_skills()} skills."
916 + f"You can activate at most {max_active_skills} skills."
917 )
918
919 _store_context_active_skill_entries(
@@ -1219,7 +1266,11 @@ def _store_context_active_skill_entries(
1266 key: str,
1267 entries: list[ActiveSkillEntry],
1268 ) -> None:
1222 - normalized_entries = normalize_active_skills(entries)
1269 + agent = context.get_agent() if hasattr(context, "get_agent") else None
1270 + normalized_entries = normalize_active_skills(
1271 + entries,
1272 + limit=get_max_active_skills(agent=agent),
1273 + )
1274 context.set_data(key, normalized_entries or None)
1275
1276
plugins/_skills/README.md
+1 -1
@@ -22,7 +22,7 @@ The shared active-skill state and prompt-resolution logic live in `helpers/skill
22 ## Notes
23
24 - keep the active list short because every active skill is injected into prompt extras every turn
25 -- the framework-wide cap is 20 active skills
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
plugins/_skills/api/skills_catalog.py
+4 -1
@@ -114,7 +114,10 @@ class SkillsCatalog(ApiHandler):
114 "context_id": context.id if context else "",
115 "project_name": project_name,
116 "skills": catalog,
117 - "max_active_skills": skills.get_max_active_skills(),
117 + "max_active_skills": skills.get_max_active_skills(
118 + agent=agent,
119 + project_name=project_name,
120 + ),
121 "active_skills": [
122 self._serialize_entry(
123 entry,
plugins/_skills/default_config.yaml
+1
@@ -1,2 +1,3 @@
1 +max_active_skills: 20
2 active_skills: []
3 hidden_skills: []
plugins/_skills/webui/config-store.js
+57 -4
@@ -9,6 +9,20 @@ import {
9 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 +}
25 +
26 function normalizeEntry(entry) {
27 if (!entry) return null;
28 if (typeof entry === "string") {
@@ -48,10 +62,11 @@ function entriesMatch(left, right) {
62
63 function ensureConfig(config) {
64 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 : [];
67 const hiddenSkills = Array.isArray(config.hidden_skills) ? config.hidden_skills : [];
68
54 - config.active_skills = compactEntries(activeSkills, MAX_ACTIVE_SKILLS_FALLBACK);
69 + config.active_skills = compactEntries(activeSkills, config.max_active_skills);
70 config.hidden_skills = compactEntries(hiddenSkills);
71 }
72
@@ -87,6 +102,7 @@ window.createSkillsConfigModel = (context, config) => ({
102
103 initDefaults() {
104 ensureConfig(config);
105 + this.maxActiveSkills = config.max_active_skills;
106 this.mode = this.isChatMode ? "visible" : "pinned";
107 this.selectedSkills = [...this.activeEntries];
108 this.hiddenSkills = [...this.hiddenEntries];
@@ -110,6 +126,36 @@ window.createSkillsConfigModel = (context, config) => ({
126 return this.selectedSkills.length;
127 },
128
129 + async applyMaxActiveSkills(value, { notify = false } = {}) {
130 + ensureConfig(config);
131 +
132 + const nextLimit = normalizeMaxActiveSkills(value);
133 + const previousLimit = this.maxActiveSkills;
134 + const previousCount = this.selectedSkills.length;
135 +
136 + config.max_active_skills = nextLimit;
137 + this.maxActiveSkills = nextLimit;
138 +
139 + if (previousCount > nextLimit) {
140 + this._setSelectedSkills(this.selectedSkills.slice(0, nextLimit));
141 +
142 + if (notify) {
143 + await toastFrontendInfo(
144 + `Trimmed ${previousCount - nextLimit} pinned skill${previousCount - nextLimit === 1 ? "" : "s"} to match the new cap of ${nextLimit}.`,
145 + "Skills"
146 + );
147 + }
148 + return;
149 + }
150 +
151 + if (notify && previousLimit !== nextLimit) {
152 + await toastFrontendInfo(
153 + `Pinned skill cap set to ${nextLimit}.`,
154 + "Skills"
155 + );
156 + }
157 + },
158 +
159 get catalogMap() {
160 const byKey = new Map();
161 for (const skill of this.catalog) {
@@ -346,11 +392,15 @@ window.createSkillsConfigModel = (context, config) => ({
392 }
393
394 this.catalog = Array.isArray(response.skills) ? response.skills : [];
349 - this.maxActiveSkills = Number(response.max_active_skills) || MAX_ACTIVE_SKILLS_FALLBACK;
395 + this.maxActiveSkills = normalizeMaxActiveSkills(response.max_active_skills);
396 + if (!this.isChatMode) {
397 + config.max_active_skills = this.maxActiveSkills;
398 + }
399 this.applyCatalogState(response);
400 } catch (error) {
401 this.catalog = [];
353 - this.maxActiveSkills = MAX_ACTIVE_SKILLS_FALLBACK;
402 + ensureConfig(config);
403 + this.maxActiveSkills = config.max_active_skills;
404 this.chatContextAvailable = false;
405 this._setSelectedSkills(this.activeEntries);
406 this._setHiddenSkills(this.hiddenEntries);
@@ -382,7 +432,10 @@ window.createSkillsConfigModel = (context, config) => ({
432 }
433
434 this.catalog = Array.isArray(response.skills) ? response.skills : this.catalog;
385 - this.maxActiveSkills = Number(response.max_active_skills) || this.maxActiveSkills;
435 + this.maxActiveSkills = normalizeMaxActiveSkills(response.max_active_skills);
436 + if (!this.isChatMode) {
437 + config.max_active_skills = this.maxActiveSkills;
438 + }
439 this.chatContextAvailable = !!response.context_available;
440 this.applyCatalogState(response);
441 return true;
plugins/_skills/webui/config.html
+20
@@ -15,6 +15,26 @@
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 +
38 <div class="skills-toolbar">
39 <label class="skills-search">
40 <span class="material-symbols-outlined">search</span>
tests/test_skills_runtime.py
+58 -2
@@ -94,6 +94,7 @@ runtime = _load_skills_helper_module()
94 class DummyContext:
95 def __init__(self):
96 self.data = {}
97 + self.agent = None
98
99 def get_data(self, key, recursive=True):
100 return self.data.get(key)
@@ -101,15 +102,26 @@ class DummyContext:
102 def set_data(self, key, value, recursive=True):
103 self.data[key] = value
104
105 + def get_agent(self):
106 + return self.agent
107 +
108
109 class DummyAgent:
110 def __init__(self):
111 self.context = DummyContext()
112 + self.context.agent = self
113 self.data = {}
114
115
111 -def _scope_config(entries):
112 - return {"active_skills": entries}
116 +def _scope_config(entries=None, *, hidden_entries=None, max_active_skills=None):
117 + config = {}
118 + if entries is not None:
119 + config["active_skills"] = entries
120 + if hidden_entries is not None:
121 + config["hidden_skills"] = hidden_entries
122 + if max_active_skills is not None:
123 + config["max_active_skills"] = max_active_skills
124 + return config
125
126
127 def test_active_skills_cap_is_twenty():
@@ -117,6 +129,18 @@ def test_active_skills_cap_is_twenty():
129 assert runtime.get_max_active_skills() == 20
130
131
132 +def test_skills_config_can_raise_active_cap_above_default():
133 + config = runtime.normalize_skills_config(
134 + {
135 + "max_active_skills": 25,
136 + "active_skills": [{"name": f"Skill {index}"} for index in range(25)],
137 + }
138 + )
139 +
140 + assert config["max_active_skills"] == 25
141 + assert len(config["active_skills"]) == 25
142 +
143 +
144 def test_hidden_skills_are_not_capped_like_active_skills():
145 agent = DummyAgent()
146 entries = [{"name": f"Hidden {index}"} for index in range(25)]
@@ -396,6 +420,38 @@ def test_activating_new_skill_fails_once_limit_is_full(monkeypatch):
420 assert len(runtime.get_active_skills(agent)) == 20
421
422
423 +def test_chat_activation_respects_scope_configured_cap(monkeypatch):
424 + monkeypatch.setattr(
425 + runtime.plugin_helpers,
426 + "get_plugin_config",
427 + lambda *args, **kwargs: _scope_config(max_active_skills=25),
428 + )
429 + agent = DummyAgent()
430 +
431 + for index in range(21):
432 + runtime.activate_chat_skill(agent, {"name": f"Extra {index}"})
433 +
434 + assert len(runtime.get_chat_active_skills(agent.context)) == 21
435 + assert len(runtime.get_active_skills(agent)) == 21
436 +
437 +
438 +def test_activating_new_skill_uses_scope_configured_limit(monkeypatch):
439 + monkeypatch.setattr(
440 + runtime.plugin_helpers,
441 + "get_plugin_config",
442 + lambda *args, **kwargs: _scope_config(
443 + [{"name": f"Pinned {index}"} for index in range(3)],
444 + max_active_skills=3,
445 + ),
446 + )
447 + agent = DummyAgent()
448 +
449 + with pytest.raises(ValueError, match="at most 3"):
450 + runtime.activate_chat_skill(agent, {"name": "Overflow"})
451 +
452 + assert len(runtime.get_active_skills(agent)) == 3
453 +
454 +
455 def test_hidden_skills_filter_agent_visible_skill_catalog(monkeypatch, tmp_path: Path):
456 skills_root = tmp_path / "skills"
457 for name in ("alpha-skill", "beta-skill"):