From 3314a5471bf08c5a7216afd46e017fd2e2088db5 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 2 Apr 2026 14:00:12 -0700 Subject: [PATCH 1/2] 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 c1d01ba32..46db04900 100644 --- a/docs/features/custom-agents.md +++ b/docs/features/custom-agents.md @@ -216,6 +216,7 @@ await using var session = await client.CreateSessionAsync(new SessionConfig | `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. @@ -225,6 +226,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 3bc9294aa..eb6f968ef 100644 --- a/docs/features/skills.md +++ b/docs/features/skills.md @@ -316,20 +316,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 d03ed95fa..cceb34c2e 100644 --- a/docs/features/streaming-events.md +++ b/docs/features/streaming-events.md @@ -618,6 +618,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 80410c27a..8e3e37562 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -1515,6 +1515,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 524280fd8..9ee493c8b 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 9f23dcb85..7368c576b 100644 --- a/go/types.go +++ b/go/types.go @@ -416,6 +416,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 1af6e76c6..71fc0068b 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 c20bf00db..176a59f47 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 96bb4730b..d77e6e3e5 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -748,6 +748,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 518a67ef20e0f489fa38cf104cd1fd8850ca22b0 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 3 Apr 2026 10:43:14 -0700 Subject: [PATCH 2/2] 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 46db04900..22a81859a 100644 --- a/docs/features/custom-agents.md +++ b/docs/features/custom-agents.md @@ -216,7 +216,7 @@ await using var session = await client.CreateSessionAsync(new SessionConfig | `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. @@ -228,7 +228,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({ @@ -251,7 +251,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 eb6f968ef..5f9940762 100644 --- a/docs/features/skills.md +++ b/docs/features/skills.md @@ -316,7 +316,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({ @@ -331,7 +331,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 8e3e37562..f23add528 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -1517,10 +1517,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 9ee493c8b..e42d4c823 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 7368c576b..ececf8592 100644 --- a/go/types.go +++ b/go/types.go @@ -416,7 +416,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 176a59f47..3bcba8d6f 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 d77e6e3e5..330b1e864 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -748,7 +748,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