Skip to content

Commit 3314a54

Browse files
feat: add per-agent skills support to SDK types and docs (#958)
Add a 'skills' field to CustomAgentConfig across all four SDK languages (Node.js, Python, Go, .NET) to support scoping skills to individual subagents. Skills are opt-in: agents get no skills by default. Changes: - Add skills?: string[] to CustomAgentConfig in all SDKs - Update custom-agents.md with skills in config table and new section - Update skills.md with per-agent skills example and opt-in note - Update streaming-events.md with agentName on skill.invoked event - Add E2E tests for agent-scoped skills in all four SDKs - Add snapshot YAML files for new test scenarios Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent dd42d42 commit 3314a54

14 files changed

Lines changed: 366 additions & 3 deletions

docs/features/custom-agents.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ await using var session = await client.CreateSessionAsync(new SessionConfig
216216
| `prompt` | `string` || System prompt for the agent |
217217
| `mcpServers` | `object` | | MCP server configurations specific to this agent |
218218
| `infer` | `boolean` | | Whether the runtime can auto-select this agent (default: `true`) |
219+
| `skills` | `string[]` | | List of skill names available to this agent |
219220

220221
> **Tip:** A good `description` helps the runtime match user intent to the right agent. Be specific about the agent's expertise and capabilities.
221222
@@ -225,6 +226,33 @@ In addition to per-agent configuration above, you can set `agent` on the **sessi
225226
|-------------------------|------|-------------|
226227
| `agent` | `string` | Name of the custom agent to pre-select at session creation. Must match a `name` in `customAgents`. |
227228

229+
## Per-Agent Skills
230+
231+
You can scope skills to individual agents using the `skills` property. Skills are **opt-in** — agents have no access to skills by default. The skill names listed in `skills` are resolved from the session-level `skillDirectories`.
232+
233+
```typescript
234+
const session = await client.createSession({
235+
skillDirectories: ["./skills"],
236+
customAgents: [
237+
{
238+
name: "security-auditor",
239+
description: "Security-focused code reviewer",
240+
prompt: "Focus on OWASP Top 10 vulnerabilities",
241+
skills: ["security-scan", "dependency-check"],
242+
},
243+
{
244+
name: "docs-writer",
245+
description: "Technical documentation writer",
246+
prompt: "Write clear, concise documentation",
247+
skills: ["markdown-lint"],
248+
},
249+
],
250+
onPermissionRequest: async () => ({ kind: "approved" }),
251+
});
252+
```
253+
254+
In this example, `security-auditor` can invoke only `security-scan` and `dependency-check`, while `docs-writer` can invoke only `markdown-lint`. An agent without a `skills` field has no access to any skills.
255+
228256
## Selecting an Agent at Session Creation
229257

230258
You can pass `agent` in the session config to pre-select which custom agent should be active when the session starts. The value must match the `name` of one of the agents defined in `customAgents`.

docs/features/skills.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -316,20 +316,23 @@ The markdown body contains the instructions that are injected into the session c
316316

317317
### Skills + Custom Agents
318318

319-
Skills work alongside custom agents:
319+
Skills can be scoped to individual custom agents using the `skills` property. Skills are **opt-in** — agents get no skills by default. Skill names are resolved from the session-level `skillDirectories`.
320320

321321
```typescript
322322
const session = await client.createSession({
323-
skillDirectories: ["./skills/security"],
323+
skillDirectories: ["./skills"],
324324
customAgents: [{
325325
name: "security-auditor",
326326
description: "Security-focused code reviewer",
327327
prompt: "Focus on OWASP Top 10 vulnerabilities",
328+
skills: ["security-scan", "dependency-check"],
328329
}],
329330
onPermissionRequest: async () => ({ kind: "approved" }),
330331
});
331332
```
332333

334+
> **Note:** When `skills` is omitted, the agent has **no** access to skills. This is an opt-in model — you must explicitly list the skills each agent needs.
335+
333336
### Skills + MCP Servers
334337

335338
Skills can complement MCP server capabilities:

docs/features/streaming-events.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,7 @@ A skill was activated for the current conversation.
618618
| `allowedTools` | `string[]` | | Tools auto-approved while this skill is active |
619619
| `pluginName` | `string` | | Plugin the skill originated from |
620620
| `pluginVersion` | `string` | | Plugin version |
621+
| `agentName` | `string` | | Name of the agent that invoked the skill, when invoked by a custom agent |
621622

622623
---
623624

dotnet/src/Types.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1515,6 +1515,15 @@ public class CustomAgentConfig
15151515
/// </summary>
15161516
[JsonPropertyName("infer")]
15171517
public bool? Infer { get; set; }
1518+
1519+
/// <summary>
1520+
/// List of skill names available to this agent.
1521+
/// Skills are resolved by name from the session's loaded skill pool (configured via skillDirectories).
1522+
/// When set, only the listed skills can be invoked by this agent.
1523+
/// When omitted, the agent has no access to skills (opt-in model).
1524+
/// </summary>
1525+
[JsonPropertyName("skills")]
1526+
public List<string>? Skills { get; set; }
15181527
}
15191528

15201529
/// <summary>

dotnet/test/SkillsTests.cs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,67 @@ public async Task Should_Not_Apply_Skill_When_Disabled_Via_DisabledSkills()
8787
await session.DisposeAsync();
8888
}
8989

90+
[Fact]
91+
public async Task Should_Allow_Agent_With_Skills_To_Invoke_Skill()
92+
{
93+
var skillsDir = CreateSkillDir();
94+
var customAgents = new List<CustomAgentConfig>
95+
{
96+
new CustomAgentConfig
97+
{
98+
Name = "skill-agent",
99+
Description = "An agent with access to test-skill",
100+
Prompt = "You are a helpful test agent.",
101+
Skills = ["test-skill"]
102+
}
103+
};
104+
105+
var session = await CreateSessionAsync(new SessionConfig
106+
{
107+
SkillDirectories = [skillsDir],
108+
CustomAgents = customAgents
109+
});
110+
111+
Assert.Matches(@"^[a-f0-9-]+$", session.SessionId);
112+
113+
// The agent has Skills = ["test-skill"], so it should be able to invoke the skill
114+
var message = await session.SendAndWaitAsync(new MessageOptions { Prompt = "Say hello briefly using the test skill." });
115+
Assert.NotNull(message);
116+
Assert.Contains(SkillMarker, message!.Data.Content);
117+
118+
await session.DisposeAsync();
119+
}
120+
121+
[Fact]
122+
public async Task Should_Not_Provide_Skills_To_Agent_Without_Skills_Field()
123+
{
124+
var skillsDir = CreateSkillDir();
125+
var customAgents = new List<CustomAgentConfig>
126+
{
127+
new CustomAgentConfig
128+
{
129+
Name = "no-skill-agent",
130+
Description = "An agent without skills access",
131+
Prompt = "You are a helpful test agent."
132+
}
133+
};
134+
135+
var session = await CreateSessionAsync(new SessionConfig
136+
{
137+
SkillDirectories = [skillsDir],
138+
CustomAgents = customAgents
139+
});
140+
141+
Assert.Matches(@"^[a-f0-9-]+$", session.SessionId);
142+
143+
// The agent has no Skills field, so it should NOT have access to skills
144+
var message = await session.SendAndWaitAsync(new MessageOptions { Prompt = "Say hello briefly using the test skill." });
145+
Assert.NotNull(message);
146+
Assert.DoesNotContain(SkillMarker, message!.Data.Content);
147+
148+
await session.DisposeAsync();
149+
}
150+
90151
[Fact(Skip = "See the big comment around the equivalent test in the Node SDK. Skipped because the feature doesn't work correctly yet.")]
91152
public async Task Should_Apply_Skill_On_Session_Resume_With_SkillDirectories()
92153
{

go/internal/e2e/skills_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,81 @@ func TestSkills(t *testing.T) {
108108
session.Disconnect()
109109
})
110110

111+
t.Run("should allow agent with skills to invoke skill", func(t *testing.T) {
112+
ctx.ConfigureForTest(t)
113+
cleanSkillsDir(t, ctx.WorkDir)
114+
skillsDir := createTestSkillDir(t, ctx.WorkDir, skillMarker)
115+
116+
customAgents := []copilot.CustomAgentConfig{
117+
{
118+
Name: "skill-agent",
119+
Description: "An agent with access to test-skill",
120+
Prompt: "You are a helpful test agent.",
121+
Skills: []string{"test-skill"},
122+
},
123+
}
124+
125+
session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{
126+
OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
127+
SkillDirectories: []string{skillsDir},
128+
CustomAgents: customAgents,
129+
})
130+
if err != nil {
131+
t.Fatalf("Failed to create session: %v", err)
132+
}
133+
134+
// The agent has Skills: ["test-skill"], so it should be able to invoke the skill
135+
message, err := session.SendAndWait(t.Context(), copilot.MessageOptions{
136+
Prompt: "Say hello briefly using the test skill.",
137+
})
138+
if err != nil {
139+
t.Fatalf("Failed to send message: %v", err)
140+
}
141+
142+
if message.Data.Content == nil || !strings.Contains(*message.Data.Content, skillMarker) {
143+
t.Errorf("Expected message to contain skill marker '%s', got: %v", skillMarker, message.Data.Content)
144+
}
145+
146+
session.Disconnect()
147+
})
148+
149+
t.Run("should not provide skills to agent without skills field", func(t *testing.T) {
150+
ctx.ConfigureForTest(t)
151+
cleanSkillsDir(t, ctx.WorkDir)
152+
skillsDir := createTestSkillDir(t, ctx.WorkDir, skillMarker)
153+
154+
customAgents := []copilot.CustomAgentConfig{
155+
{
156+
Name: "no-skill-agent",
157+
Description: "An agent without skills access",
158+
Prompt: "You are a helpful test agent.",
159+
},
160+
}
161+
162+
session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{
163+
OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
164+
SkillDirectories: []string{skillsDir},
165+
CustomAgents: customAgents,
166+
})
167+
if err != nil {
168+
t.Fatalf("Failed to create session: %v", err)
169+
}
170+
171+
// The agent has no Skills field, so it should NOT have access to skills
172+
message, err := session.SendAndWait(t.Context(), copilot.MessageOptions{
173+
Prompt: "Say hello briefly using the test skill.",
174+
})
175+
if err != nil {
176+
t.Fatalf("Failed to send message: %v", err)
177+
}
178+
179+
if message.Data.Content != nil && strings.Contains(*message.Data.Content, skillMarker) {
180+
t.Errorf("Expected message to NOT contain skill marker '%s' when agent has no skills, got: %v", skillMarker, *message.Data.Content)
181+
}
182+
183+
session.Disconnect()
184+
})
185+
111186
t.Run("should apply skill on session resume with skillDirectories", func(t *testing.T) {
112187
t.Skip("See the big comment around the equivalent test in the Node SDK. Skipped because the feature doesn't work correctly yet.")
113188
ctx.ConfigureForTest(t)

go/types.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,8 @@ type CustomAgentConfig struct {
416416
MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"`
417417
// Infer indicates whether the agent should be available for model inference
418418
Infer *bool `json:"infer,omitempty"`
419+
// Skills is the list of skill names available to this agent (opt-in; omit for no skills)
420+
Skills []string `json:"skills,omitempty"`
419421
}
420422

421423
// InfiniteSessionConfig configures infinite sessions with automatic context compaction

nodejs/package-lock.json

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

nodejs/src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1006,6 +1006,13 @@ export interface CustomAgentConfig {
10061006
* @default true
10071007
*/
10081008
infer?: boolean;
1009+
/**
1010+
* List of skill names available to this agent.
1011+
* Skills are resolved by name from the session's loaded skill pool (configured via `skillDirectories`).
1012+
* When set, only the listed skills can be invoked by this agent.
1013+
* When omitted, the agent has no access to skills (opt-in model).
1014+
*/
1015+
skills?: string[];
10091016
}
10101017

10111018
/**

0 commit comments

Comments
 (0)