Skip to content

Commit a2e9461

Browse files
authored
Merge pull request #2474 from dgageot/board/support-boolean-or-array-skills-in-yaml-f97b09f6
Support filtering skills by name in agent YAML (#2404)
2 parents 7901a6a + aadc69b commit a2e9461

11 files changed

Lines changed: 484 additions & 46 deletions

File tree

agent-schema.json

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -397,8 +397,26 @@
397397
"description": "Lifecycle hooks for executing shell commands at various points in the agent's execution"
398398
},
399399
"skills": {
400-
"type": "boolean",
401-
"description": "Enable skills discovery for this agent. When enabled, the agent can discover and load skill files (SKILL.md) from the workspace."
400+
"description": "Enable skills discovery for this agent. Set to true to load all discovered skills from local filesystem sources; false disables skills. A list can mix sources (\"local\" or an HTTP/HTTPS URL) and/or skill names to include. If only names are given, local sources are loaded and filtered to just those skills.",
401+
"oneOf": [
402+
{
403+
"type": "boolean",
404+
"description": "When true, loads all discovered local skills. When false, skills are disabled."
405+
},
406+
{
407+
"type": "array",
408+
"description": "List combining skill sources and/or skill names to include. Items equal to \"local\" or starting with http:// or https:// are treated as sources; any other string is treated as a skill name filter.",
409+
"items": {
410+
"type": "string"
411+
},
412+
"examples": [
413+
["local"],
414+
["local", "https://skills.example.com"],
415+
["git", "docker"],
416+
["local", "docker-build"]
417+
]
418+
}
419+
]
402420
}
403421
},
404422
"additionalProperties": false

docs/configuration/agents/index.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ agents:
3333
max_consecutive_tool_calls: int # Optional: max identical consecutive tool calls
3434
max_old_tool_call_tokens: int # Optional: token budget for old tool call content
3535
num_history_items: int # Optional: limit conversation history
36-
skills: boolean # Optional: enable skill discovery
36+
skills: boolean | [list] # Optional: enable skill discovery (true/false or list of names and/or sources)
3737
commands: # Optional: named prompts
3838
name: "prompt text"
3939
welcome_message: string # Optional: message shown at session start
@@ -78,7 +78,7 @@ agents:
7878
| `max_old_tool_call_tokens` | int | ✗ | Maximum number of tokens to keep from old tool call arguments and results. Older tool calls beyond this budget have their content replaced with a placeholder, saving context space. Tokens are approximated as `len/4`. Set to `-1` to disable truncation (unlimited). Default: `40000`. |
7979
| `num_history_items` | int | ✗ | Limit the number of conversation history messages sent to the model. Useful for managing context window size with long conversations. Default: unlimited (all messages sent). |
8080
| `rag` | array | ✗ | List of RAG source names to attach to this agent. References sources defined in the top-level `rag` section. See [RAG]({{ '/features/rag/' | relative_url }}). |
81-
| `skills` | boolean | ✗ | Enable automatic skill discovery from standard directories. |
81+
| `skills` | bool/array | ✗ | Enable automatic skill discovery. `true` loads all discovered local skills, `false` disables them. A list can mix skill sources (`local` or `https://…` URLs) and skill names to include — see [Skills]({{ '/features/skills/' | relative_url }}). |
8282
| `commands` | object | ✗ | Named prompts that can be run with `docker agent run config.yaml /command_name`. |
8383
| `welcome_message` | string | ✗ | Message displayed to the user when a session starts. Useful for providing context or instructions. |
8484
| `handoffs` | array | ✗ | List of agent names this agent can hand off the conversation to. Enables the `handoff` tool. See [Handoffs Routing]({{ '/concepts/multi-agent/#handoffs-routing' | relative_url }}). |

docs/features/skills/index.md

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,40 @@ agents:
3434
3535
</div>
3636
37+
## Filtering Skills
38+
39+
The `skills` field also accepts a list, letting you restrict the agent to a specific subset of skills instead of exposing every discovered one. List items are classified automatically:
40+
41+
- `"local"` or any `http://` / `https://` URL → a **source** to load skills from
42+
- any other string → the **name** of a skill to include
43+
44+
When only names are given, local sources are used by default.
45+
46+
```yaml
47+
agents:
48+
# Load every discovered local skill (same as `skills: true`).
49+
full:
50+
skills: true
51+
52+
# Load local skills, but only expose "commit" and "poem".
53+
scoped:
54+
skills:
55+
- commit
56+
- poem
57+
58+
# Combine an explicit source with a name filter.
59+
remote_filtered:
60+
skills:
61+
- https://skills.example.com
62+
- commit
63+
64+
# Disable skills entirely.
65+
none:
66+
skills: false
67+
```
68+
69+
A name that doesn't match any discovered skill is logged as a warning at startup but is otherwise ignored.
70+
3771
## SKILL.md Format
3872
3973
<!-- yaml-lint:skip -->
@@ -171,11 +205,11 @@ When asked to create a Dockerfile:
171205
EOF
172206
```
173207

174-
The skill will automatically be available to any agent with `skills: true`.
208+
The skill will automatically be available to any agent with skills enabled (`skills: true`, or a list that targets its name — see [Filtering Skills](#filtering-skills)).
175209

176210
<div class="callout callout-info" markdown="1">
177211
<div class="callout-title">ℹ️ See also
178212
</div>
179-
<p>Skills are enabled in the <a href="{{ '/configuration/agents/' | relative_url }}">Agent Config</a> with the <code>skills: true</code> property. For tool-based capabilities, see <a href="{{ '/concepts/tools/' | relative_url }}">Tools</a>.</p>
213+
<p>Skills are enabled in the <a href="{{ '/configuration/agents/' | relative_url }}">Agent Config</a> with the <code>skills</code> property (boolean or list). For tool-based capabilities, see <a href="{{ '/concepts/tools/' | relative_url }}">Tools</a>.</p>
180214

181215
</div>

examples/skills_filter.yaml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
#!yaml
2+
# Skills filter example.
3+
#
4+
# The `skills` field accepts either:
5+
# - `true` / `false` to enable/disable skills discovery entirely.
6+
# - A list combining skill sources and/or skill names to include.
7+
#
8+
# Items equal to "local" or starting with http:// or https:// are treated as
9+
# sources (where to discover skills from). Any other string is treated as the
10+
# name of a specific skill to expose to the agent. When only skill names are
11+
# given, the `local` source is used by default.
12+
#
13+
# The agent below loads skills from the local workspace but only exposes the
14+
# `commit` and `poem` skills. Any other discovered skills (including the
15+
# built-in `bump-go-dependencies`) will be hidden from the model.
16+
agents:
17+
root:
18+
model: openai/gpt-4o-mini
19+
description: A small agent that demonstrates filtering skills by name.
20+
instruction: |
21+
You are a helpful assistant. Use the available skills when the user's
22+
request matches one.
23+
# Only expose the "commit" and "poem" skills from the locally discovered set.
24+
skills:
25+
- commit
26+
- poem
27+
toolsets:
28+
- type: shell
29+
- type: filesystem

pkg/config/config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,5 +247,10 @@ func validateSkillsConfiguration(_ string, agent *latest.AgentConfig) error {
247247
return fmt.Errorf("agent '%s' has unknown skills source '%s' (must be 'local' or an HTTP/HTTPS URL)", agent.Name, source)
248248
}
249249
}
250+
for _, name := range agent.Skills.Include {
251+
if strings.TrimSpace(name) == "" {
252+
return fmt.Errorf("agent '%s' has an empty skills entry", agent.Name)
253+
}
254+
}
250255
return nil
251256
}

pkg/config/latest/skills_config_test.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,30 @@ func TestSkillsConfig_UnmarshalYAML(t *testing.T) {
5454
"http://internal.corp",
5555
}},
5656
},
57+
{
58+
name: "list of skill names implies local source",
59+
input: "[git, docker]",
60+
expected: SkillsConfig{
61+
Sources: []string{"local"},
62+
Include: []string{"git", "docker"},
63+
},
64+
},
65+
{
66+
name: "list mixing local source and skill names",
67+
input: "[local, git]",
68+
expected: SkillsConfig{
69+
Sources: []string{"local"},
70+
Include: []string{"git"},
71+
},
72+
},
73+
{
74+
name: "list mixing remote source and skill names",
75+
input: "[\"https://skills.example.com\", git]",
76+
expected: SkillsConfig{
77+
Sources: []string{"https://skills.example.com"},
78+
Include: []string{"git"},
79+
},
80+
},
5781
}
5882

5983
for _, tt := range tests {
@@ -92,6 +116,22 @@ func TestSkillsConfig_MarshalYAML(t *testing.T) {
92116
input: SkillsConfig{Sources: []string{"https://example.com"}},
93117
expected: "- https://example.com\n",
94118
},
119+
{
120+
name: "include with default local source omits local",
121+
input: SkillsConfig{
122+
Sources: []string{"local"},
123+
Include: []string{"git", "docker"},
124+
},
125+
expected: "- git\n- docker\n",
126+
},
127+
{
128+
name: "include with explicit remote keeps both",
129+
input: SkillsConfig{
130+
Sources: []string{"https://example.com"},
131+
Include: []string{"git"},
132+
},
133+
expected: "- https://example.com\n- git\n",
134+
},
95135
}
96136

97137
for _, tt := range tests {
@@ -129,6 +169,22 @@ func TestSkillsConfig_UnmarshalJSON(t *testing.T) {
129169
input: `["local", "https://skills.example.com"]`,
130170
expected: SkillsConfig{Sources: []string{"local", "https://skills.example.com"}},
131171
},
172+
{
173+
name: "list with skill names defaults to local",
174+
input: `["git", "docker"]`,
175+
expected: SkillsConfig{
176+
Sources: []string{"local"},
177+
Include: []string{"git", "docker"},
178+
},
179+
},
180+
{
181+
name: "list mixing source and names",
182+
input: `["local", "git"]`,
183+
expected: SkillsConfig{
184+
Sources: []string{"local"},
185+
Include: []string{"git"},
186+
},
187+
},
132188
}
133189

134190
for _, tt := range tests {
@@ -162,6 +218,22 @@ func TestSkillsConfig_MarshalJSON(t *testing.T) {
162218
input: SkillsConfig{Sources: []string{"local", "https://example.com"}},
163219
expected: `["local","https://example.com"]`,
164220
},
221+
{
222+
name: "include with default local source omits local",
223+
input: SkillsConfig{
224+
Sources: []string{"local"},
225+
Include: []string{"git", "docker"},
226+
},
227+
expected: `["git","docker"]`,
228+
},
229+
{
230+
name: "include with remote source keeps both",
231+
input: SkillsConfig{
232+
Sources: []string{"https://example.com"},
233+
Include: []string{"git"},
234+
},
235+
expected: `["https://example.com","git"]`,
236+
},
165237
}
166238

167239
for _, tt := range tests {
@@ -268,3 +340,72 @@ toolsets:
268340
assert.True(t, agent.Skills.HasLocal())
269341
assert.Empty(t, agent.Skills.RemoteURLs())
270342
}
343+
344+
func TestSkillsConfig_InAgentConfigIncludeOnly(t *testing.T) {
345+
yamlInput := `
346+
model: openai/gpt-4
347+
skills:
348+
- git
349+
- docker
350+
toolsets:
351+
- type: filesystem
352+
`
353+
var agent AgentConfig
354+
err := yaml.Unmarshal([]byte(yamlInput), &agent)
355+
require.NoError(t, err)
356+
assert.True(t, agent.Skills.Enabled())
357+
assert.True(t, agent.Skills.HasLocal())
358+
assert.Equal(t, []string{"git", "docker"}, agent.Skills.Include)
359+
}
360+
361+
func TestSkillsConfig_InAgentConfigMixedSourcesAndIncludes(t *testing.T) {
362+
yamlInput := `
363+
model: openai/gpt-4
364+
skills:
365+
- local
366+
- https://skills.example.com
367+
- git
368+
toolsets:
369+
- type: filesystem
370+
`
371+
var agent AgentConfig
372+
err := yaml.Unmarshal([]byte(yamlInput), &agent)
373+
require.NoError(t, err)
374+
assert.Equal(t, []string{"local", "https://skills.example.com"}, agent.Skills.Sources)
375+
assert.Equal(t, []string{"git"}, agent.Skills.Include)
376+
}
377+
378+
func TestSkillsConfig_EmptyListIsDisabled(t *testing.T) {
379+
// An empty list (no sources and no names) means disabled, like `skills: false`.
380+
var s SkillsConfig
381+
require.NoError(t, yaml.Unmarshal([]byte("[]"), &s))
382+
assert.False(t, s.Enabled())
383+
assert.Empty(t, s.Include)
384+
385+
s = SkillsConfig{}
386+
require.NoError(t, json.Unmarshal([]byte("[]"), &s))
387+
assert.False(t, s.Enabled())
388+
assert.Empty(t, s.Include)
389+
}
390+
391+
func TestSkillsConfig_UnmarshalResetsReceiver(t *testing.T) {
392+
// Unmarshaling into an already-populated receiver must not leak previous state.
393+
t.Run("bool into populated receiver", func(t *testing.T) {
394+
s := SkillsConfig{Sources: []string{"https://old"}, Include: []string{"old"}}
395+
require.NoError(t, yaml.Unmarshal([]byte("true"), &s))
396+
assert.Equal(t, []string{"local"}, s.Sources)
397+
assert.Nil(t, s.Include)
398+
})
399+
t.Run("list into populated receiver", func(t *testing.T) {
400+
s := SkillsConfig{Sources: []string{"https://old"}, Include: []string{"old"}}
401+
require.NoError(t, yaml.Unmarshal([]byte("[git]"), &s))
402+
assert.Equal(t, []string{"local"}, s.Sources)
403+
assert.Equal(t, []string{"git"}, s.Include)
404+
})
405+
t.Run("false into populated receiver", func(t *testing.T) {
406+
s := SkillsConfig{Sources: []string{"https://old"}, Include: []string{"old"}}
407+
require.NoError(t, json.Unmarshal([]byte("false"), &s))
408+
assert.Nil(t, s.Sources)
409+
assert.Nil(t, s.Include)
410+
})
411+
}

0 commit comments

Comments
 (0)