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