From 48302679bfee12fd8850cfae82fc3f4a40dfc8ff Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 2 Apr 2026 14:00:12 -0700 Subject: [PATCH 1/5] 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/features/custom-agents.md | 28 +++++++ docs/features/skills.md | 7 +- docs/features/streaming-events.md | 1 + dotnet/src/Types.cs | 9 +++ dotnet/test/SkillsTests.cs | 61 +++++++++++++++ go/internal/e2e/skills_test.go | 75 +++++++++++++++++++ go/types.go | 2 + nodejs/package-lock.json | 8 ++ nodejs/src/types.ts | 7 ++ nodejs/test/e2e/skills.test.ts | 58 ++++++++++++++ python/copilot/session.py | 2 + python/e2e/test_skills.py | 57 +++++++++++++- ...low_agent_with_skills_to_invoke_skill.yaml | 44 +++++++++++ ..._skills_to_agent_without_skills_field.yaml | 10 +++ 14 files changed, 366 insertions(+), 3 deletions(-) create mode 100644 test/snapshots/skills/should_allow_agent_with_skills_to_invoke_skill.yaml create mode 100644 test/snapshots/skills/should_not_provide_skills_to_agent_without_skills_field.yaml diff --git a/docs/features/custom-agents.md b/docs/features/custom-agents.md index 6c6455a02..b6cb6e96b 100644 --- a/docs/features/custom-agents.md +++ b/docs/features/custom-agents.md @@ -252,6 +252,7 @@ try (var client = new CopilotClient()) { | `prompt` | `string` | ✅ | System prompt for the agent | | `mcpServers` | `object` | | MCP server configurations specific to this agent | | `infer` | `boolean` | | Whether the runtime can auto-select this agent (default: `true`) | +| `skills` | `string[]` | | List of skill names available to this agent | > **Tip:** A good `description` helps the runtime match user intent to the right agent. Be specific about the agent's expertise and capabilities. @@ -261,6 +262,33 @@ In addition to per-agent configuration above, you can set `agent` on the **sessi |-------------------------|------|-------------| | `agent` | `string` | Name of the custom agent to pre-select at session creation. Must match a `name` in `customAgents`. | +## Per-Agent Skills + +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`. + +```typescript +const session = await client.createSession({ + skillDirectories: ["./skills"], + customAgents: [ + { + name: "security-auditor", + description: "Security-focused code reviewer", + prompt: "Focus on OWASP Top 10 vulnerabilities", + skills: ["security-scan", "dependency-check"], + }, + { + name: "docs-writer", + description: "Technical documentation writer", + prompt: "Write clear, concise documentation", + skills: ["markdown-lint"], + }, + ], + onPermissionRequest: async () => ({ kind: "approved" }), +}); +``` + +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. + ## Selecting an Agent at Session Creation 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`. diff --git a/docs/features/skills.md b/docs/features/skills.md index 882580fd4..8f1e90dc3 100644 --- a/docs/features/skills.md +++ b/docs/features/skills.md @@ -364,20 +364,23 @@ The markdown body contains the instructions that are injected into the session c ### Skills + Custom Agents -Skills work alongside custom agents: +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`. ```typescript const session = await client.createSession({ - skillDirectories: ["./skills/security"], + skillDirectories: ["./skills"], customAgents: [{ name: "security-auditor", description: "Security-focused code reviewer", prompt: "Focus on OWASP Top 10 vulnerabilities", + skills: ["security-scan", "dependency-check"], }], onPermissionRequest: async () => ({ kind: "approved" }), }); ``` +> **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. + ### Skills + MCP Servers Skills can complement MCP server capabilities: diff --git a/docs/features/streaming-events.md b/docs/features/streaming-events.md index 9dde8f21b..5fd903ef7 100644 --- a/docs/features/streaming-events.md +++ b/docs/features/streaming-events.md @@ -633,6 +633,7 @@ A skill was activated for the current conversation. | `allowedTools` | `string[]` | | Tools auto-approved while this skill is active | | `pluginName` | `string` | | Plugin the skill originated from | | `pluginVersion` | `string` | | Plugin version | +| `agentName` | `string` | | Name of the agent that invoked the skill, when invoked by a custom agent | --- diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index d8262e140..951836e20 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -1546,6 +1546,15 @@ public class CustomAgentConfig /// [JsonPropertyName("infer")] public bool? Infer { get; set; } + + /// + /// List of skill names available to this agent. + /// Skills are resolved by name from the session's loaded skill pool (configured via skillDirectories). + /// When set, only the listed skills can be invoked by this agent. + /// When omitted, the agent has no access to skills (opt-in model). + /// + [JsonPropertyName("skills")] + public List? Skills { get; set; } } /// diff --git a/dotnet/test/SkillsTests.cs b/dotnet/test/SkillsTests.cs index d68eed79d..4f1baed99 100644 --- a/dotnet/test/SkillsTests.cs +++ b/dotnet/test/SkillsTests.cs @@ -87,6 +87,67 @@ public async Task Should_Not_Apply_Skill_When_Disabled_Via_DisabledSkills() await session.DisposeAsync(); } + [Fact] + public async Task Should_Allow_Agent_With_Skills_To_Invoke_Skill() + { + var skillsDir = CreateSkillDir(); + var customAgents = new List + { + new CustomAgentConfig + { + Name = "skill-agent", + Description = "An agent with access to test-skill", + Prompt = "You are a helpful test agent.", + Skills = ["test-skill"] + } + }; + + var session = await CreateSessionAsync(new SessionConfig + { + SkillDirectories = [skillsDir], + CustomAgents = customAgents + }); + + Assert.Matches(@"^[a-f0-9-]+$", session.SessionId); + + // The agent has Skills = ["test-skill"], so it should be able to invoke the skill + var message = await session.SendAndWaitAsync(new MessageOptions { Prompt = "Say hello briefly using the test skill." }); + Assert.NotNull(message); + Assert.Contains(SkillMarker, message!.Data.Content); + + await session.DisposeAsync(); + } + + [Fact] + public async Task Should_Not_Provide_Skills_To_Agent_Without_Skills_Field() + { + var skillsDir = CreateSkillDir(); + var customAgents = new List + { + new CustomAgentConfig + { + Name = "no-skill-agent", + Description = "An agent without skills access", + Prompt = "You are a helpful test agent." + } + }; + + var session = await CreateSessionAsync(new SessionConfig + { + SkillDirectories = [skillsDir], + CustomAgents = customAgents + }); + + Assert.Matches(@"^[a-f0-9-]+$", session.SessionId); + + // The agent has no Skills field, so it should NOT have access to skills + var message = await session.SendAndWaitAsync(new MessageOptions { Prompt = "Say hello briefly using the test skill." }); + Assert.NotNull(message); + Assert.DoesNotContain(SkillMarker, message!.Data.Content); + + await session.DisposeAsync(); + } + [Fact(Skip = "See the big comment around the equivalent test in the Node SDK. Skipped because the feature doesn't work correctly yet.")] public async Task Should_Apply_Skill_On_Session_Resume_With_SkillDirectories() { diff --git a/go/internal/e2e/skills_test.go b/go/internal/e2e/skills_test.go index f6943fef9..1a392d0c2 100644 --- a/go/internal/e2e/skills_test.go +++ b/go/internal/e2e/skills_test.go @@ -108,6 +108,81 @@ func TestSkills(t *testing.T) { session.Disconnect() }) + t.Run("should allow agent with skills to invoke skill", func(t *testing.T) { + ctx.ConfigureForTest(t) + cleanSkillsDir(t, ctx.WorkDir) + skillsDir := createTestSkillDir(t, ctx.WorkDir, skillMarker) + + customAgents := []copilot.CustomAgentConfig{ + { + Name: "skill-agent", + Description: "An agent with access to test-skill", + Prompt: "You are a helpful test agent.", + Skills: []string{"test-skill"}, + }, + } + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + SkillDirectories: []string{skillsDir}, + CustomAgents: customAgents, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // The agent has Skills: ["test-skill"], so it should be able to invoke the skill + message, err := session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Say hello briefly using the test skill.", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + if message.Data.Content == nil || !strings.Contains(*message.Data.Content, skillMarker) { + t.Errorf("Expected message to contain skill marker '%s', got: %v", skillMarker, message.Data.Content) + } + + session.Disconnect() + }) + + t.Run("should not provide skills to agent without skills field", func(t *testing.T) { + ctx.ConfigureForTest(t) + cleanSkillsDir(t, ctx.WorkDir) + skillsDir := createTestSkillDir(t, ctx.WorkDir, skillMarker) + + customAgents := []copilot.CustomAgentConfig{ + { + Name: "no-skill-agent", + Description: "An agent without skills access", + Prompt: "You are a helpful test agent.", + }, + } + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + SkillDirectories: []string{skillsDir}, + CustomAgents: customAgents, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // The agent has no Skills field, so it should NOT have access to skills + message, err := session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Say hello briefly using the test skill.", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + if message.Data.Content != nil && strings.Contains(*message.Data.Content, skillMarker) { + t.Errorf("Expected message to NOT contain skill marker '%s' when agent has no skills, got: %v", skillMarker, *message.Data.Content) + } + + session.Disconnect() + }) + t.Run("should apply skill on session resume with skillDirectories", func(t *testing.T) { t.Skip("See the big comment around the equivalent test in the Node SDK. Skipped because the feature doesn't work correctly yet.") ctx.ConfigureForTest(t) diff --git a/go/types.go b/go/types.go index c26f075e3..e9161e675 100644 --- a/go/types.go +++ b/go/types.go @@ -422,6 +422,8 @@ type CustomAgentConfig struct { MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` // Infer indicates whether the agent should be available for model inference Infer *bool `json:"infer,omitempty"` + // Skills is the list of skill names available to this agent (opt-in; omit for no skills) + Skills []string `json:"skills,omitempty"` } // InfiniteSessionConfig configures infinite sessions with automatic context compaction diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 84754e70f..81d840b29 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1260,6 +1260,7 @@ "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -1299,6 +1300,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -1627,6 +1629,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1954,6 +1957,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2862,6 +2866,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3280,6 +3285,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -3313,6 +3319,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3380,6 +3387,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index c2d095234..9ffadfb29 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1006,6 +1006,13 @@ export interface CustomAgentConfig { * @default true */ infer?: boolean; + /** + * List of skill names available to this agent. + * Skills are resolved by name from the session's loaded skill pool (configured via `skillDirectories`). + * When set, only the listed skills can be invoked by this agent. + * When omitted, the agent has no access to skills (opt-in model). + */ + skills?: string[]; } /** diff --git a/nodejs/test/e2e/skills.test.ts b/nodejs/test/e2e/skills.test.ts index a2173648f..6efc1a64d 100644 --- a/nodejs/test/e2e/skills.test.ts +++ b/nodejs/test/e2e/skills.test.ts @@ -5,6 +5,7 @@ import * as fs from "fs"; import * as path from "path"; import { beforeEach, describe, expect, it } from "vitest"; +import type { CustomAgentConfig } from "../../src/index.js"; import { approveAll } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; @@ -92,6 +93,63 @@ IMPORTANT: You MUST include the exact text "${SKILL_MARKER}" somewhere in EVERY // Also, if this test runs FIRST and then the "should load and apply skill from skillDirectories" test runs second // within the same run (i.e., sharing the same Client instance), then the second test fails too. There's definitely // some state being shared or cached incorrectly. + it("should allow agent with skills to invoke skill", async () => { + const skillsDir = createSkillDir(); + const customAgents: CustomAgentConfig[] = [ + { + name: "skill-agent", + description: "An agent with access to test-skill", + prompt: "You are a helpful test agent.", + skills: ["test-skill"], + }, + ]; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + skillDirectories: [skillsDir], + customAgents, + }); + + expect(session.sessionId).toBeDefined(); + + // The agent has skills: ["test-skill"], so it should be able to invoke the skill + const message = await session.sendAndWait({ + prompt: "Say hello briefly using the test skill.", + }); + + expect(message?.data.content).toContain(SKILL_MARKER); + + await session.disconnect(); + }); + + it("should not provide skills to agent without skills field", async () => { + const skillsDir = createSkillDir(); + const customAgents: CustomAgentConfig[] = [ + { + name: "no-skill-agent", + description: "An agent without skills access", + prompt: "You are a helpful test agent.", + }, + ]; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + skillDirectories: [skillsDir], + customAgents, + }); + + expect(session.sessionId).toBeDefined(); + + // The agent has no skills field, so it should NOT have access to skills + const message = await session.sendAndWait({ + prompt: "Say hello briefly using the test skill.", + }); + + expect(message?.data.content).not.toContain(SKILL_MARKER); + + await session.disconnect(); + }); + it.skip("should apply skill on session resume with skillDirectories", async () => { const skillsDir = createSkillDir(); diff --git a/python/copilot/session.py b/python/copilot/session.py index b3f62789d..2ebdbe8a4 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -766,6 +766,8 @@ class CustomAgentConfig(TypedDict, total=False): # MCP servers specific to agent mcp_servers: NotRequired[dict[str, MCPServerConfig]] infer: NotRequired[bool] # Whether agent is available for model inference + # List of skill names available to this agent (opt-in; omit for no skills) + skills: NotRequired[list[str]] class InfiniteSessionConfig(TypedDict, total=False): diff --git a/python/e2e/test_skills.py b/python/e2e/test_skills.py index feacae73b..d264d8bf8 100644 --- a/python/e2e/test_skills.py +++ b/python/e2e/test_skills.py @@ -7,7 +7,7 @@ import pytest -from copilot.session import PermissionHandler +from copilot.session import CustomAgentConfig, PermissionHandler from .testharness import E2ETestContext @@ -88,6 +88,61 @@ async def test_should_not_apply_skill_when_disabled_via_disabledskills( await session.disconnect() + async def test_should_allow_agent_with_skills_to_invoke_skill(self, ctx: E2ETestContext): + """Test that an agent with skills can invoke the specified skill""" + skills_dir = create_skill_dir(ctx.work_dir) + custom_agents: list[CustomAgentConfig] = [ + { + "name": "skill-agent", + "description": "An agent with access to test-skill", + "prompt": "You are a helpful test agent.", + "skills": ["test-skill"], + } + ] + + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + skill_directories=[skills_dir], + custom_agents=custom_agents, + ) + + assert session.session_id is not None + + # The agent has skills: ["test-skill"], so it should be able to invoke the skill + message = await session.send_and_wait("Say hello briefly using the test skill.") + assert message is not None + assert SKILL_MARKER in message.data.content + + await session.disconnect() + + async def test_should_not_provide_skills_to_agent_without_skills_field( + self, ctx: E2ETestContext + ): + """Test that an agent without skills field gets no skills (opt-in model)""" + skills_dir = create_skill_dir(ctx.work_dir) + custom_agents: list[CustomAgentConfig] = [ + { + "name": "no-skill-agent", + "description": "An agent without skills access", + "prompt": "You are a helpful test agent.", + } + ] + + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + skill_directories=[skills_dir], + custom_agents=custom_agents, + ) + + assert session.session_id is not None + + # The agent has no skills field, so it should NOT have access to skills + message = await session.send_and_wait("Say hello briefly using the test skill.") + assert message is not None + assert SKILL_MARKER not in message.data.content + + await session.disconnect() + @pytest.mark.skip( reason="See the big comment around the equivalent test in the Node SDK. " "Skipped because the feature doesn't work correctly yet." diff --git a/test/snapshots/skills/should_allow_agent_with_skills_to_invoke_skill.yaml b/test/snapshots/skills/should_allow_agent_with_skills_to_invoke_skill.yaml new file mode 100644 index 000000000..7d364fcbd --- /dev/null +++ b/test/snapshots/skills/should_allow_agent_with_skills_to_invoke_skill.yaml @@ -0,0 +1,44 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Say hello briefly using the test skill. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: skill + arguments: '{"skill":"test-skill"}' + - role: tool + tool_call_id: toolcall_0 + content: Skill "test-skill" loaded successfully. Follow the instructions in the skill context. + - role: user + content: >- + + + Base directory for this skill: ${workdir}/.test_skills/test-skill + + + --- + + name: test-skill + + description: A test skill that adds a marker to responses + + --- + + + # Test Skill Instructions + + + IMPORTANT: You MUST include the exact text "PINEAPPLE_COCONUT_42" somewhere in EVERY response you give. This + is a mandatory requirement. Include it naturally in your response. + + + + - role: assistant + content: Hello! PINEAPPLE_COCONUT_42 - I'm ready to help you with your tasks today. diff --git a/test/snapshots/skills/should_not_provide_skills_to_agent_without_skills_field.yaml b/test/snapshots/skills/should_not_provide_skills_to_agent_without_skills_field.yaml new file mode 100644 index 000000000..0c678deab --- /dev/null +++ b/test/snapshots/skills/should_not_provide_skills_to_agent_without_skills_field.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Say hello briefly using the test skill. + - role: assistant + content: Hello! I'm GitHub Copilot CLI, ready to help you with your software engineering tasks. From a9cece2a7546ac5ad4db368ac85277e888d9e53d Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 3 Apr 2026 10:43:14 -0700 Subject: [PATCH 2/5] 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/features/custom-agents.md | 6 +++--- docs/features/skills.md | 4 ++-- dotnet/src/Types.cs | 9 +++++---- dotnet/test/SkillsTests.cs | 4 ++-- go/internal/e2e/skills_test.go | 4 ++-- go/types.go | 2 +- nodejs/src/types.ts | 9 +++++---- nodejs/test/e2e/skills.test.ts | 4 ++-- python/copilot/session.py | 2 +- python/e2e/test_skills.py | 8 ++++---- 10 files changed, 27 insertions(+), 25 deletions(-) diff --git a/docs/features/custom-agents.md b/docs/features/custom-agents.md index b6cb6e96b..f3c508922 100644 --- a/docs/features/custom-agents.md +++ b/docs/features/custom-agents.md @@ -252,7 +252,7 @@ try (var client = new CopilotClient()) { | `prompt` | `string` | ✅ | System prompt for the agent | | `mcpServers` | `object` | | MCP server configurations specific to this agent | | `infer` | `boolean` | | Whether the runtime can auto-select this agent (default: `true`) | -| `skills` | `string[]` | | List of skill names available to this agent | +| `skills` | `string[]` | | Skill names to preload into the agent's context at startup | > **Tip:** A good `description` helps the runtime match user intent to the right agent. Be specific about the agent's expertise and capabilities. @@ -264,7 +264,7 @@ In addition to per-agent configuration above, you can set `agent` on the **sessi ## Per-Agent Skills -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`. +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`. ```typescript const session = await client.createSession({ @@ -287,7 +287,7 @@ const session = await client.createSession({ }); ``` -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. +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. ## Selecting an Agent at Session Creation diff --git a/docs/features/skills.md b/docs/features/skills.md index 8f1e90dc3..cb42f4674 100644 --- a/docs/features/skills.md +++ b/docs/features/skills.md @@ -364,7 +364,7 @@ The markdown body contains the instructions that are injected into the session c ### Skills + Custom Agents -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`. +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`. ```typescript const session = await client.createSession({ @@ -379,7 +379,7 @@ const session = await client.createSession({ }); ``` -> **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. +> **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. ### Skills + MCP Servers diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 951836e20..8603a1f9c 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -1548,10 +1548,11 @@ public class CustomAgentConfig public bool? Infer { get; set; } /// - /// List of skill names available to this agent. - /// Skills are resolved by name from the session's loaded skill pool (configured via skillDirectories). - /// When set, only the listed skills can be invoked by this agent. - /// When omitted, the agent has no access to skills (opt-in model). + /// List of skill names to preload into this agent's context. + /// When set, the full content of each listed skill is eagerly injected into + /// the agent's context at startup. Skills are resolved by name from the + /// session's configured skill directories (skillDirectories). + /// When omitted, no skills are injected (opt-in model). /// [JsonPropertyName("skills")] public List? Skills { get; set; } diff --git a/dotnet/test/SkillsTests.cs b/dotnet/test/SkillsTests.cs index 4f1baed99..6082549b3 100644 --- a/dotnet/test/SkillsTests.cs +++ b/dotnet/test/SkillsTests.cs @@ -110,7 +110,7 @@ public async Task Should_Allow_Agent_With_Skills_To_Invoke_Skill() Assert.Matches(@"^[a-f0-9-]+$", session.SessionId); - // The agent has Skills = ["test-skill"], so it should be able to invoke the skill + // The agent has Skills = ["test-skill"], so the skill content is preloaded into its context var message = await session.SendAndWaitAsync(new MessageOptions { Prompt = "Say hello briefly using the test skill." }); Assert.NotNull(message); Assert.Contains(SkillMarker, message!.Data.Content); @@ -140,7 +140,7 @@ public async Task Should_Not_Provide_Skills_To_Agent_Without_Skills_Field() Assert.Matches(@"^[a-f0-9-]+$", session.SessionId); - // The agent has no Skills field, so it should NOT have access to skills + // The agent has no Skills field, so no skill content is injected var message = await session.SendAndWaitAsync(new MessageOptions { Prompt = "Say hello briefly using the test skill." }); Assert.NotNull(message); Assert.DoesNotContain(SkillMarker, message!.Data.Content); diff --git a/go/internal/e2e/skills_test.go b/go/internal/e2e/skills_test.go index 1a392d0c2..79fbab24f 100644 --- a/go/internal/e2e/skills_test.go +++ b/go/internal/e2e/skills_test.go @@ -131,7 +131,7 @@ func TestSkills(t *testing.T) { t.Fatalf("Failed to create session: %v", err) } - // The agent has Skills: ["test-skill"], so it should be able to invoke the skill + // The agent has Skills: ["test-skill"], so the skill content is preloaded into its context message, err := session.SendAndWait(t.Context(), copilot.MessageOptions{ Prompt: "Say hello briefly using the test skill.", }) @@ -168,7 +168,7 @@ func TestSkills(t *testing.T) { t.Fatalf("Failed to create session: %v", err) } - // The agent has no Skills field, so it should NOT have access to skills + // The agent has no Skills field, so no skill content is injected message, err := session.SendAndWait(t.Context(), copilot.MessageOptions{ Prompt: "Say hello briefly using the test skill.", }) diff --git a/go/types.go b/go/types.go index e9161e675..aae4aa2bb 100644 --- a/go/types.go +++ b/go/types.go @@ -422,7 +422,7 @@ type CustomAgentConfig struct { MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` // Infer indicates whether the agent should be available for model inference Infer *bool `json:"infer,omitempty"` - // Skills is the list of skill names available to this agent (opt-in; omit for no skills) + // Skills is the list of skill names to preload into this agent's context at startup (opt-in; omit for none) Skills []string `json:"skills,omitempty"` } diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 9ffadfb29..8dd04adce 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1007,10 +1007,11 @@ export interface CustomAgentConfig { */ infer?: boolean; /** - * List of skill names available to this agent. - * Skills are resolved by name from the session's loaded skill pool (configured via `skillDirectories`). - * When set, only the listed skills can be invoked by this agent. - * When omitted, the agent has no access to skills (opt-in model). + * List of skill names to preload into this agent's context. + * When set, the full content of each listed skill is eagerly injected into + * the agent's context at startup. Skills are resolved by name from the + * session's configured skill directories (`skillDirectories`). + * When omitted, no skills are injected (opt-in model). */ skills?: string[]; } diff --git a/nodejs/test/e2e/skills.test.ts b/nodejs/test/e2e/skills.test.ts index 6efc1a64d..5683ea062 100644 --- a/nodejs/test/e2e/skills.test.ts +++ b/nodejs/test/e2e/skills.test.ts @@ -112,7 +112,7 @@ IMPORTANT: You MUST include the exact text "${SKILL_MARKER}" somewhere in EVERY expect(session.sessionId).toBeDefined(); - // The agent has skills: ["test-skill"], so it should be able to invoke the skill + // The agent has skills: ["test-skill"], so the skill content is preloaded into its context const message = await session.sendAndWait({ prompt: "Say hello briefly using the test skill.", }); @@ -140,7 +140,7 @@ IMPORTANT: You MUST include the exact text "${SKILL_MARKER}" somewhere in EVERY expect(session.sessionId).toBeDefined(); - // The agent has no skills field, so it should NOT have access to skills + // The agent has no skills field, so no skill content is injected const message = await session.sendAndWait({ prompt: "Say hello briefly using the test skill.", }); diff --git a/python/copilot/session.py b/python/copilot/session.py index 2ebdbe8a4..cd1785973 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -766,7 +766,7 @@ class CustomAgentConfig(TypedDict, total=False): # MCP servers specific to agent mcp_servers: NotRequired[dict[str, MCPServerConfig]] infer: NotRequired[bool] # Whether agent is available for model inference - # List of skill names available to this agent (opt-in; omit for no skills) + # Skill names to preload into this agent's context at startup (opt-in; omit for none) skills: NotRequired[list[str]] diff --git a/python/e2e/test_skills.py b/python/e2e/test_skills.py index d264d8bf8..ce943185b 100644 --- a/python/e2e/test_skills.py +++ b/python/e2e/test_skills.py @@ -89,7 +89,7 @@ async def test_should_not_apply_skill_when_disabled_via_disabledskills( await session.disconnect() async def test_should_allow_agent_with_skills_to_invoke_skill(self, ctx: E2ETestContext): - """Test that an agent with skills can invoke the specified skill""" + """Test that an agent with skills gets skill content preloaded into context""" skills_dir = create_skill_dir(ctx.work_dir) custom_agents: list[CustomAgentConfig] = [ { @@ -108,7 +108,7 @@ async def test_should_allow_agent_with_skills_to_invoke_skill(self, ctx: E2ETest assert session.session_id is not None - # The agent has skills: ["test-skill"], so it should be able to invoke the skill + # The agent has skills: ["test-skill"], so the skill content is preloaded into its context message = await session.send_and_wait("Say hello briefly using the test skill.") assert message is not None assert SKILL_MARKER in message.data.content @@ -118,7 +118,7 @@ async def test_should_allow_agent_with_skills_to_invoke_skill(self, ctx: E2ETest async def test_should_not_provide_skills_to_agent_without_skills_field( self, ctx: E2ETestContext ): - """Test that an agent without skills field gets no skills (opt-in model)""" + """Test that an agent without skills field gets no skill content (opt-in model)""" skills_dir = create_skill_dir(ctx.work_dir) custom_agents: list[CustomAgentConfig] = [ { @@ -136,7 +136,7 @@ async def test_should_not_provide_skills_to_agent_without_skills_field( assert session.session_id is not None - # The agent has no skills field, so it should NOT have access to skills + # The agent has no skills field, so no skill content is injected message = await session.send_and_wait("Say hello briefly using the test skill.") assert message is not None assert SKILL_MARKER not in message.data.content From e7e962100a6e5a5d32266239558a0c0cbffe2ba9 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 9 Apr 2026 10:16:54 -0700 Subject: [PATCH 3/5] 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> --- docs/features/streaming-events.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/features/streaming-events.md b/docs/features/streaming-events.md index 5fd903ef7..9dde8f21b 100644 --- a/docs/features/streaming-events.md +++ b/docs/features/streaming-events.md @@ -633,7 +633,6 @@ A skill was activated for the current conversation. | `allowedTools` | `string[]` | | Tools auto-approved while this skill is active | | `pluginName` | `string` | | Plugin the skill originated from | | `pluginVersion` | `string` | | Plugin version | -| `agentName` | `string` | | Name of the agent that invoked the skill, when invoked by a custom agent | --- From 0042058ca2a959309effdc87c3533c6287e04472 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 9 Apr 2026 10:23:32 -0700 Subject: [PATCH 4/5] 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> --- dotnet/test/SkillsTests.cs | 6 ++++-- go/internal/e2e/skills_test.go | 2 ++ nodejs/test/e2e/skills.test.ts | 2 ++ python/copilot/client.py | 2 ++ python/e2e/test_skills.py | 2 ++ 5 files changed, 12 insertions(+), 2 deletions(-) diff --git a/dotnet/test/SkillsTests.cs b/dotnet/test/SkillsTests.cs index 6082549b3..0cae1f58f 100644 --- a/dotnet/test/SkillsTests.cs +++ b/dotnet/test/SkillsTests.cs @@ -105,7 +105,8 @@ public async Task Should_Allow_Agent_With_Skills_To_Invoke_Skill() var session = await CreateSessionAsync(new SessionConfig { SkillDirectories = [skillsDir], - CustomAgents = customAgents + CustomAgents = customAgents, + Agent = "skill-agent" }); Assert.Matches(@"^[a-f0-9-]+$", session.SessionId); @@ -135,7 +136,8 @@ public async Task Should_Not_Provide_Skills_To_Agent_Without_Skills_Field() var session = await CreateSessionAsync(new SessionConfig { SkillDirectories = [skillsDir], - CustomAgents = customAgents + CustomAgents = customAgents, + Agent = "no-skill-agent" }); Assert.Matches(@"^[a-f0-9-]+$", session.SessionId); diff --git a/go/internal/e2e/skills_test.go b/go/internal/e2e/skills_test.go index 79fbab24f..31e358635 100644 --- a/go/internal/e2e/skills_test.go +++ b/go/internal/e2e/skills_test.go @@ -126,6 +126,7 @@ func TestSkills(t *testing.T) { OnPermissionRequest: copilot.PermissionHandler.ApproveAll, SkillDirectories: []string{skillsDir}, CustomAgents: customAgents, + Agent: "skill-agent", }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -163,6 +164,7 @@ func TestSkills(t *testing.T) { OnPermissionRequest: copilot.PermissionHandler.ApproveAll, SkillDirectories: []string{skillsDir}, CustomAgents: customAgents, + Agent: "no-skill-agent", }) if err != nil { t.Fatalf("Failed to create session: %v", err) diff --git a/nodejs/test/e2e/skills.test.ts b/nodejs/test/e2e/skills.test.ts index 5683ea062..973e2f329 100644 --- a/nodejs/test/e2e/skills.test.ts +++ b/nodejs/test/e2e/skills.test.ts @@ -108,6 +108,7 @@ IMPORTANT: You MUST include the exact text "${SKILL_MARKER}" somewhere in EVERY onPermissionRequest: approveAll, skillDirectories: [skillsDir], customAgents, + agent: "skill-agent", }); expect(session.sessionId).toBeDefined(); @@ -136,6 +137,7 @@ IMPORTANT: You MUST include the exact text "${SKILL_MARKER}" somewhere in EVERY onPermissionRequest: approveAll, skillDirectories: [skillsDir], customAgents, + agent: "no-skill-agent", }); expect(session.sessionId).toBeDefined(); diff --git a/python/copilot/client.py b/python/copilot/client.py index d260dcc91..2078dfc6c 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -2158,6 +2158,8 @@ def _convert_custom_agent_to_wire_format( wire_agent["mcpServers"] = agent["mcp_servers"] if "infer" in agent: wire_agent["infer"] = agent["infer"] + if "skills" in agent: + wire_agent["skills"] = agent["skills"] return wire_agent async def _start_cli_server(self) -> None: diff --git a/python/e2e/test_skills.py b/python/e2e/test_skills.py index ce943185b..b5c5e6e7c 100644 --- a/python/e2e/test_skills.py +++ b/python/e2e/test_skills.py @@ -104,6 +104,7 @@ async def test_should_allow_agent_with_skills_to_invoke_skill(self, ctx: E2ETest on_permission_request=PermissionHandler.approve_all, skill_directories=[skills_dir], custom_agents=custom_agents, + agent="skill-agent", ) assert session.session_id is not None @@ -132,6 +133,7 @@ async def test_should_not_provide_skills_to_agent_without_skills_field( on_permission_request=PermissionHandler.approve_all, skill_directories=[skills_dir], custom_agents=custom_agents, + agent="no-skill-agent", ) assert session.session_id is not None From c1272a1dea60233d55de14049a46b5636bfbe929 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 9 Apr 2026 10:42:19 -0700 Subject: [PATCH 5/5] 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> --- go/internal/e2e/skills_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/go/internal/e2e/skills_test.go b/go/internal/e2e/skills_test.go index 31e358635..b91592d9d 100644 --- a/go/internal/e2e/skills_test.go +++ b/go/internal/e2e/skills_test.go @@ -140,8 +140,8 @@ func TestSkills(t *testing.T) { t.Fatalf("Failed to send message: %v", err) } - if message.Data.Content == nil || !strings.Contains(*message.Data.Content, skillMarker) { - t.Errorf("Expected message to contain skill marker '%s', got: %v", skillMarker, message.Data.Content) + if md, ok := message.Data.(*copilot.AssistantMessageData); !ok || !strings.Contains(md.Content, skillMarker) { + t.Errorf("Expected message to contain skill marker '%s', got: %v", skillMarker, message.Data) } session.Disconnect() @@ -178,8 +178,8 @@ func TestSkills(t *testing.T) { t.Fatalf("Failed to send message: %v", err) } - if message.Data.Content != nil && strings.Contains(*message.Data.Content, skillMarker) { - t.Errorf("Expected message to NOT contain skill marker '%s' when agent has no skills, got: %v", skillMarker, *message.Data.Content) + if md, ok := message.Data.(*copilot.AssistantMessageData); ok && strings.Contains(md.Content, skillMarker) { + t.Errorf("Expected message to NOT contain skill marker '%s' when agent has no skills, got: %v", skillMarker, md.Content) } session.Disconnect()