Skip to content

Commit 3e1b65e

Browse files
feat: add per-agent skills support (#995)
* 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> * docs: update skills semantics to eager injection model Update type comments, docs, and test descriptions to reflect that per-agent skills are eagerly injected into the agent's context at startup rather than filtered for invocation. Sub-agents do not inherit skills from the parent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: remove agentName from skill.invoked event table The runtime does not emit agentName on the skill.invoked event. The agent name is used only for internal logging during eager skill loading, not as event data. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address PR review feedback for per-agent skills (#995) - Add skills field to Python wire format converter - Explicitly select agents in all E2E tests for deterministic behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: update Go skills tests to use typed SessionEventData after rebase The generated_session_events.go on main changed from a flat Data struct to a SessionEventData interface with per-event typed structs. The agent skills test cases added in this PR were using the old message.Data.Content pattern instead of the type assertion pattern used elsewhere. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: revert unintentional package-lock.json changes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: update proxy and snapshots for eager skill injection The runtime now eagerly injects skill content into <agent_instructions> in the user message instead of using a skill tool call. Update the replay proxy to strip <agent_instructions> during normalization, and simplify the snapshot for agent-with-skills to match the new flow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: add agent_instructions normalization tests for replay proxy Add two regression tests validating that <agent_instructions> blocks are properly stripped during user message normalization, including the case where skill-context is nested inside agent_instructions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: use IList<string> for Skills property in .NET SDK Match the established convention used by Tools, SkillDirectories, DisabledSkills, and other collection properties in the codebase. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: restore original skillDirectories path in skills.md sample Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: use see cref for SkillDirectories in XML doc Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 06e4964 commit 3e1b65e

File tree

15 files changed

+380
-2
lines changed

15 files changed

+380
-2
lines changed

docs/features/custom-agents.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ try (var client = new CopilotClient()) {
252252
| `prompt` | `string` || System prompt for the agent |
253253
| `mcpServers` | `object` | | MCP server configurations specific to this agent |
254254
| `infer` | `boolean` | | Whether the runtime can auto-select this agent (default: `true`) |
255+
| `skills` | `string[]` | | Skill names to preload into the agent's context at startup |
255256

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

265+
## Per-Agent Skills
266+
267+
You can preload skills into an agent's context using the `skills` property. When specified, the **full content** of each listed skill is eagerly injected into the agent's context at startup — the agent doesn't need to invoke a skill tool; the instructions are already present. Skills are **opt-in**: agents receive no skills by default, and sub-agents do not inherit skills from the parent. Skill names are resolved from the session-level `skillDirectories`.
268+
269+
```typescript
270+
const session = await client.createSession({
271+
skillDirectories: ["./skills"],
272+
customAgents: [
273+
{
274+
name: "security-auditor",
275+
description: "Security-focused code reviewer",
276+
prompt: "Focus on OWASP Top 10 vulnerabilities",
277+
skills: ["security-scan", "dependency-check"],
278+
},
279+
{
280+
name: "docs-writer",
281+
description: "Technical documentation writer",
282+
prompt: "Write clear, concise documentation",
283+
skills: ["markdown-lint"],
284+
},
285+
],
286+
onPermissionRequest: async () => ({ kind: "approved" }),
287+
});
288+
```
289+
290+
In this example, `security-auditor` starts with `security-scan` and `dependency-check` already injected into its context, while `docs-writer` starts with `markdown-lint`. An agent without a `skills` field receives no skill content.
291+
264292
## Selecting an Agent at Session Creation
265293

266294
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: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ The markdown body contains the instructions that are injected into the session c
364364

365365
### Skills + Custom Agents
366366

367-
Skills work alongside custom agents:
367+
Skills listed in an agent's `skills` field are **eagerly preloaded** — their full content is injected into the agent's context at startup, so the agent has access to the skill instructions immediately without needing to invoke a skill tool. Skill names are resolved from the session-level `skillDirectories`.
368368

369369
```typescript
370370
const session = await client.createSession({
@@ -373,10 +373,12 @@ const session = await client.createSession({
373373
name: "security-auditor",
374374
description: "Security-focused code reviewer",
375375
prompt: "Focus on OWASP Top 10 vulnerabilities",
376+
skills: ["security-scan", "dependency-check"],
376377
}],
377378
onPermissionRequest: async () => ({ kind: "approved" }),
378379
});
379380
```
381+
> **Note:** Skills are opt-in — when `skills` is omitted, no skill content is injected. Sub-agents do not inherit skills from the parent; you must list them explicitly per agent.
380382
381383
### Skills + MCP Servers
382384

dotnet/src/Types.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1638,6 +1638,16 @@ public class CustomAgentConfig
16381638
/// </summary>
16391639
[JsonPropertyName("infer")]
16401640
public bool? Infer { get; set; }
1641+
1642+
/// <summary>
1643+
/// List of skill names to preload into this agent's context.
1644+
/// When set, the full content of each listed skill is eagerly injected into
1645+
/// the agent's context at startup. Skills are resolved by name from the
1646+
/// session's configured skill directories (<see cref="SessionConfig.SkillDirectories"/>).
1647+
/// When omitted, no skills are injected (opt-in model).
1648+
/// </summary>
1649+
[JsonPropertyName("skills")]
1650+
public IList<string>? Skills { get; set; }
16411651
}
16421652

16431653
/// <summary>

dotnet/test/SkillsTests.cs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,69 @@ 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+
Agent = "skill-agent"
110+
});
111+
112+
Assert.Matches(@"^[a-f0-9-]+$", session.SessionId);
113+
114+
// The agent has Skills = ["test-skill"], so the skill content is preloaded into its context
115+
var message = await session.SendAndWaitAsync(new MessageOptions { Prompt = "Say hello briefly using the test skill." });
116+
Assert.NotNull(message);
117+
Assert.Contains(SkillMarker, message!.Data.Content);
118+
119+
await session.DisposeAsync();
120+
}
121+
122+
[Fact]
123+
public async Task Should_Not_Provide_Skills_To_Agent_Without_Skills_Field()
124+
{
125+
var skillsDir = CreateSkillDir();
126+
var customAgents = new List<CustomAgentConfig>
127+
{
128+
new CustomAgentConfig
129+
{
130+
Name = "no-skill-agent",
131+
Description = "An agent without skills access",
132+
Prompt = "You are a helpful test agent."
133+
}
134+
};
135+
136+
var session = await CreateSessionAsync(new SessionConfig
137+
{
138+
SkillDirectories = [skillsDir],
139+
CustomAgents = customAgents,
140+
Agent = "no-skill-agent"
141+
});
142+
143+
Assert.Matches(@"^[a-f0-9-]+$", session.SessionId);
144+
145+
// The agent has no Skills field, so no skill content is injected
146+
var message = await session.SendAndWaitAsync(new MessageOptions { Prompt = "Say hello briefly using the test skill." });
147+
Assert.NotNull(message);
148+
Assert.DoesNotContain(SkillMarker, message!.Data.Content);
149+
150+
await session.DisposeAsync();
151+
}
152+
90153
[Fact(Skip = "See the big comment around the equivalent test in the Node SDK. Skipped because the feature doesn't work correctly yet.")]
91154
public async Task Should_Apply_Skill_On_Session_Resume_With_SkillDirectories()
92155
{

go/internal/e2e/skills_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,83 @@ 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+
Agent: "skill-agent",
130+
})
131+
if err != nil {
132+
t.Fatalf("Failed to create session: %v", err)
133+
}
134+
135+
// The agent has Skills: ["test-skill"], so the skill content is preloaded into its context
136+
message, err := session.SendAndWait(t.Context(), copilot.MessageOptions{
137+
Prompt: "Say hello briefly using the test skill.",
138+
})
139+
if err != nil {
140+
t.Fatalf("Failed to send message: %v", err)
141+
}
142+
143+
if md, ok := message.Data.(*copilot.AssistantMessageData); !ok || !strings.Contains(md.Content, skillMarker) {
144+
t.Errorf("Expected message to contain skill marker '%s', got: %v", skillMarker, message.Data)
145+
}
146+
147+
session.Disconnect()
148+
})
149+
150+
t.Run("should not provide skills to agent without skills field", func(t *testing.T) {
151+
ctx.ConfigureForTest(t)
152+
cleanSkillsDir(t, ctx.WorkDir)
153+
skillsDir := createTestSkillDir(t, ctx.WorkDir, skillMarker)
154+
155+
customAgents := []copilot.CustomAgentConfig{
156+
{
157+
Name: "no-skill-agent",
158+
Description: "An agent without skills access",
159+
Prompt: "You are a helpful test agent.",
160+
},
161+
}
162+
163+
session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{
164+
OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
165+
SkillDirectories: []string{skillsDir},
166+
CustomAgents: customAgents,
167+
Agent: "no-skill-agent",
168+
})
169+
if err != nil {
170+
t.Fatalf("Failed to create session: %v", err)
171+
}
172+
173+
// The agent has no Skills field, so no skill content is injected
174+
message, err := session.SendAndWait(t.Context(), copilot.MessageOptions{
175+
Prompt: "Say hello briefly using the test skill.",
176+
})
177+
if err != nil {
178+
t.Fatalf("Failed to send message: %v", err)
179+
}
180+
181+
if md, ok := message.Data.(*copilot.AssistantMessageData); ok && strings.Contains(md.Content, skillMarker) {
182+
t.Errorf("Expected message to NOT contain skill marker '%s' when agent has no skills, got: %v", skillMarker, md.Content)
183+
}
184+
185+
session.Disconnect()
186+
})
187+
111188
t.Run("should apply skill on session resume with skillDirectories", func(t *testing.T) {
112189
t.Skip("See the big comment around the equivalent test in the Node SDK. Skipped because the feature doesn't work correctly yet.")
113190
ctx.ConfigureForTest(t)

go/types.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,8 @@ type CustomAgentConfig struct {
450450
MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"`
451451
// Infer indicates whether the agent should be available for model inference
452452
Infer *bool `json:"infer,omitempty"`
453+
// Skills is the list of skill names to preload into this agent's context at startup (opt-in; omit for none)
454+
Skills []string `json:"skills,omitempty"`
453455
}
454456

455457
// InfiniteSessionConfig configures infinite sessions with automatic context compaction

nodejs/src/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1104,6 +1104,14 @@ export interface CustomAgentConfig {
11041104
* @default true
11051105
*/
11061106
infer?: boolean;
1107+
/**
1108+
* List of skill names to preload into this agent's context.
1109+
* When set, the full content of each listed skill is eagerly injected into
1110+
* the agent's context at startup. Skills are resolved by name from the
1111+
* session's configured skill directories (`skillDirectories`).
1112+
* When omitted, no skills are injected (opt-in model).
1113+
*/
1114+
skills?: string[];
11071115
}
11081116

11091117
/**

nodejs/test/e2e/skills.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import * as fs from "fs";
66
import * as path from "path";
77
import { beforeEach, describe, expect, it } from "vitest";
8+
import type { CustomAgentConfig } from "../../src/index.js";
89
import { approveAll } from "../../src/index.js";
910
import { createSdkTestContext } from "./harness/sdkTestContext.js";
1011

@@ -92,6 +93,65 @@ IMPORTANT: You MUST include the exact text "${SKILL_MARKER}" somewhere in EVERY
9293
// Also, if this test runs FIRST and then the "should load and apply skill from skillDirectories" test runs second
9394
// within the same run (i.e., sharing the same Client instance), then the second test fails too. There's definitely
9495
// some state being shared or cached incorrectly.
96+
it("should allow agent with skills to invoke skill", async () => {
97+
const skillsDir = createSkillDir();
98+
const customAgents: CustomAgentConfig[] = [
99+
{
100+
name: "skill-agent",
101+
description: "An agent with access to test-skill",
102+
prompt: "You are a helpful test agent.",
103+
skills: ["test-skill"],
104+
},
105+
];
106+
107+
const session = await client.createSession({
108+
onPermissionRequest: approveAll,
109+
skillDirectories: [skillsDir],
110+
customAgents,
111+
agent: "skill-agent",
112+
});
113+
114+
expect(session.sessionId).toBeDefined();
115+
116+
// The agent has skills: ["test-skill"], so the skill content is preloaded into its context
117+
const message = await session.sendAndWait({
118+
prompt: "Say hello briefly using the test skill.",
119+
});
120+
121+
expect(message?.data.content).toContain(SKILL_MARKER);
122+
123+
await session.disconnect();
124+
});
125+
126+
it("should not provide skills to agent without skills field", async () => {
127+
const skillsDir = createSkillDir();
128+
const customAgents: CustomAgentConfig[] = [
129+
{
130+
name: "no-skill-agent",
131+
description: "An agent without skills access",
132+
prompt: "You are a helpful test agent.",
133+
},
134+
];
135+
136+
const session = await client.createSession({
137+
onPermissionRequest: approveAll,
138+
skillDirectories: [skillsDir],
139+
customAgents,
140+
agent: "no-skill-agent",
141+
});
142+
143+
expect(session.sessionId).toBeDefined();
144+
145+
// The agent has no skills field, so no skill content is injected
146+
const message = await session.sendAndWait({
147+
prompt: "Say hello briefly using the test skill.",
148+
});
149+
150+
expect(message?.data.content).not.toContain(SKILL_MARKER);
151+
152+
await session.disconnect();
153+
});
154+
95155
it.skip("should apply skill on session resume with skillDirectories", async () => {
96156
const skillsDir = createSkillDir();
97157

python/copilot/client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2156,6 +2156,8 @@ def _convert_custom_agent_to_wire_format(
21562156
wire_agent["mcpServers"] = agent["mcp_servers"]
21572157
if "infer" in agent:
21582158
wire_agent["infer"] = agent["infer"]
2159+
if "skills" in agent:
2160+
wire_agent["skills"] = agent["skills"]
21592161
return wire_agent
21602162

21612163
async def _start_cli_server(self) -> None:

python/copilot/session.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -777,6 +777,8 @@ class CustomAgentConfig(TypedDict, total=False):
777777
# MCP servers specific to agent
778778
mcp_servers: NotRequired[dict[str, MCPServerConfig]]
779779
infer: NotRequired[bool] # Whether agent is available for model inference
780+
# Skill names to preload into this agent's context at startup (opt-in; omit for none)
781+
skills: NotRequired[list[str]]
780782

781783

782784
class InfiniteSessionConfig(TypedDict, total=False):

0 commit comments

Comments
 (0)