From c61475f7a3841b496322e5c4ddde76081975e3ee Mon Sep 17 00:00:00 2001 From: Patrick Nikoletich Date: Sat, 7 Mar 2026 23:35:54 -0800 Subject: [PATCH] feat: add agent parameter to session creation for pre-selecting custom agents Add an optional `agent` field to SessionConfig and ResumeSessionConfig across all four SDKs (Node.js, Python, Go, .NET) that allows specifying which custom agent should be active when the session starts. Previously, users had to create a session and then make a separate `session.rpc.agent.select()` call to activate a specific custom agent. This change allows setting the agent directly in the session config, equivalent to passing `--agent ` in the Copilot CLI. The `agent` value must match the `name` of one of the agents defined in `customAgents`. Changes: - Node.js: Added `agent?: string` to SessionConfig and ResumeSessionConfig, wired in client.ts for both session.create and session.resume RPC calls - Python: Added `agent: str` to SessionConfig and ResumeSessionConfig, wired in client.py for both create and resume payloads - Go: Added `Agent string` to SessionConfig and ResumeSessionConfig, wired in client.go for both request types - .NET: Added `Agent` property to SessionConfig and ResumeSessionConfig, updated copy constructors, CreateSessionRequest/ResumeSessionRequest records, and CreateSessionAsync/ResumeSessionAsync call sites - Docs: Added "Selecting an Agent at Session Creation" section with examples in all 4 languages to custom-agents.md, updated session-persistence.md and getting-started.md - Tests: Added unit tests verifying agent parameter is forwarded in both session.create and session.resume RPC calls Closes #317, closes #410, closes #547 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/getting-started.md | 2 + docs/guides/custom-agents.md | 96 ++++++++++++++++++++++++++++++ docs/guides/session-persistence.md | 1 + dotnet/src/Client.cs | 4 ++ dotnet/src/Types.cs | 14 +++++ dotnet/test/CloneTests.cs | 30 ++++++++++ go/client.go | 2 + go/client_test.go | 54 +++++++++++++++++ go/types.go | 8 +++ nodejs/src/client.ts | 2 + nodejs/src/types.ts | 8 +++ nodejs/test/client.test.ts | 52 ++++++++++++++++ python/copilot/client.py | 10 ++++ python/copilot/types.py | 6 ++ python/test_client.py | 57 ++++++++++++++++++ 15 files changed, 346 insertions(+) diff --git a/docs/getting-started.md b/docs/getting-started.md index 05bbde8dc..cd063178b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1241,6 +1241,8 @@ const session = await client.createSession({ }); ``` +> **Tip:** You can also set `agent: "pr-reviewer"` in the session config to pre-select this agent from the start. See the [Custom Agents guide](./guides/custom-agents.md#selecting-an-agent-at-session-creation) for details. + ### Customize the System Message Control the AI's behavior and personality: diff --git a/docs/guides/custom-agents.md b/docs/guides/custom-agents.md index de642e194..f9c1a3734 100644 --- a/docs/guides/custom-agents.md +++ b/docs/guides/custom-agents.md @@ -219,6 +219,102 @@ await using var session = await client.CreateSessionAsync(new SessionConfig > **Tip:** A good `description` helps the runtime match user intent to the right agent. Be specific about the agent's expertise and capabilities. +In addition to per-agent configuration above, you can set `agent` on the **session config** itself to pre-select which custom agent is active when the session starts. See [Selecting an Agent at Session Creation](#selecting-an-agent-at-session-creation) below. + +| Session Config Property | Type | Description | +|-------------------------|------|-------------| +| `agent` | `string` | Name of the custom agent to pre-select at session creation. Must match a `name` in `customAgents`. | + +## 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`. + +This is equivalent to calling `session.rpc.agent.select()` after creation, but avoids the extra API call and ensures the agent is active from the very first prompt. + +
+Node.js / TypeScript + + +```typescript +const session = await client.createSession({ + customAgents: [ + { + name: "researcher", + prompt: "You are a research assistant. Analyze code and answer questions.", + }, + { + name: "editor", + prompt: "You are a code editor. Make minimal, surgical changes.", + }, + ], + agent: "researcher", // Pre-select the researcher agent +}); +``` + +
+ +
+Python + + +```python +session = await client.create_session({ + "custom_agents": [ + { + "name": "researcher", + "prompt": "You are a research assistant. Analyze code and answer questions.", + }, + { + "name": "editor", + "prompt": "You are a code editor. Make minimal, surgical changes.", + }, + ], + "agent": "researcher", # Pre-select the researcher agent +}) +``` + +
+ +
+Go + + +```go +session, _ := client.CreateSession(ctx, &copilot.SessionConfig{ + CustomAgents: []copilot.CustomAgentConfig{ + { + Name: "researcher", + Prompt: "You are a research assistant. Analyze code and answer questions.", + }, + { + Name: "editor", + Prompt: "You are a code editor. Make minimal, surgical changes.", + }, + }, + Agent: "researcher", // Pre-select the researcher agent +}) +``` + +
+ +
+.NET + + +```csharp +var session = await client.CreateSessionAsync(new SessionConfig +{ + CustomAgents = new List + { + new() { Name = "researcher", Prompt = "You are a research assistant. Analyze code and answer questions." }, + new() { Name = "editor", Prompt = "You are a code editor. Make minimal, surgical changes." }, + }, + Agent = "researcher", // Pre-select the researcher agent +}); +``` + +
+ ## How Sub-Agent Delegation Works When you send a prompt to a session with custom agents, the runtime evaluates whether to delegate to a sub-agent: diff --git a/docs/guides/session-persistence.md b/docs/guides/session-persistence.md index df96c9ea0..8549edb60 100644 --- a/docs/guides/session-persistence.md +++ b/docs/guides/session-persistence.md @@ -248,6 +248,7 @@ When resuming a session, you can optionally reconfigure many settings. This is u | `configDir` | Override configuration directory | | `mcpServers` | Configure MCP servers | | `customAgents` | Configure custom agents | +| `agent` | Pre-select a custom agent by name | | `skillDirectories` | Directories to load skills from | | `disabledSkills` | Skills to disable | | `infiniteSessions` | Configure infinite session behavior | diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 8cad6b048..91b6353ff 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -419,6 +419,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance config.McpServers, "direct", config.CustomAgents, + config.Agent, config.ConfigDir, config.SkillDirectories, config.DisabledSkills, @@ -512,6 +513,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config.McpServers, "direct", config.CustomAgents, + config.Agent, config.SkillDirectories, config.DisabledSkills, config.InfiniteSessions); @@ -1407,6 +1409,7 @@ internal record CreateSessionRequest( Dictionary? McpServers, string? EnvValueMode, List? CustomAgents, + string? Agent, string? ConfigDir, List? SkillDirectories, List? DisabledSkills, @@ -1450,6 +1453,7 @@ internal record ResumeSessionRequest( Dictionary? McpServers, string? EnvValueMode, List? CustomAgents, + string? Agent, List? SkillDirectories, List? DisabledSkills, InfiniteSessionConfig? InfiniteSessions); diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index dbee05cfd..52d870b80 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -1197,6 +1197,7 @@ protected SessionConfig(SessionConfig? other) ClientName = other.ClientName; ConfigDir = other.ConfigDir; CustomAgents = other.CustomAgents is not null ? [.. other.CustomAgents] : null; + Agent = other.Agent; DisabledSkills = other.DisabledSkills is not null ? [.. other.DisabledSkills] : null; ExcludedTools = other.ExcludedTools is not null ? [.. other.ExcludedTools] : null; Hooks = other.Hooks; @@ -1307,6 +1308,12 @@ protected SessionConfig(SessionConfig? other) /// public List? CustomAgents { get; set; } + /// + /// Name of the custom agent to activate when the session starts. + /// Must match the of one of the agents in . + /// + public string? Agent { get; set; } + /// /// Directories to load skills from. /// @@ -1361,6 +1368,7 @@ protected ResumeSessionConfig(ResumeSessionConfig? other) ClientName = other.ClientName; ConfigDir = other.ConfigDir; CustomAgents = other.CustomAgents is not null ? [.. other.CustomAgents] : null; + Agent = other.Agent; DisabledSkills = other.DisabledSkills is not null ? [.. other.DisabledSkills] : null; DisableResume = other.DisableResume; ExcludedTools = other.ExcludedTools is not null ? [.. other.ExcludedTools] : null; @@ -1476,6 +1484,12 @@ protected ResumeSessionConfig(ResumeSessionConfig? other) /// public List? CustomAgents { get; set; } + /// + /// Name of the custom agent to activate when the session starts. + /// Must match the of one of the agents in . + /// + public string? Agent { get; set; } + /// /// Directories to load skills from. /// diff --git a/dotnet/test/CloneTests.cs b/dotnet/test/CloneTests.cs index 8982c5d64..cc6e5ad56 100644 --- a/dotnet/test/CloneTests.cs +++ b/dotnet/test/CloneTests.cs @@ -88,6 +88,7 @@ public void SessionConfig_Clone_CopiesAllProperties() Streaming = true, McpServers = new Dictionary { ["server1"] = new object() }, CustomAgents = [new CustomAgentConfig { Name = "agent1" }], + Agent = "agent1", SkillDirectories = ["/skills"], DisabledSkills = ["skill1"], }; @@ -105,6 +106,7 @@ public void SessionConfig_Clone_CopiesAllProperties() Assert.Equal(original.Streaming, clone.Streaming); Assert.Equal(original.McpServers.Count, clone.McpServers!.Count); Assert.Equal(original.CustomAgents.Count, clone.CustomAgents!.Count); + Assert.Equal(original.Agent, clone.Agent); Assert.Equal(original.SkillDirectories, clone.SkillDirectories); Assert.Equal(original.DisabledSkills, clone.DisabledSkills); } @@ -242,4 +244,32 @@ public void Clone_WithNullCollections_ReturnsNullCollections() Assert.Null(clone.DisabledSkills); Assert.Null(clone.Tools); } + + [Fact] + public void SessionConfig_Clone_CopiesAgentProperty() + { + var original = new SessionConfig + { + Agent = "test-agent", + CustomAgents = [new CustomAgentConfig { Name = "test-agent", Prompt = "You are a test agent." }], + }; + + var clone = original.Clone(); + + Assert.Equal("test-agent", clone.Agent); + } + + [Fact] + public void ResumeSessionConfig_Clone_CopiesAgentProperty() + { + var original = new ResumeSessionConfig + { + Agent = "test-agent", + CustomAgents = [new CustomAgentConfig { Name = "test-agent", Prompt = "You are a test agent." }], + }; + + var clone = original.Clone(); + + Assert.Equal("test-agent", clone.Agent); + } } diff --git a/go/client.go b/go/client.go index a43530adb..3c1fb28cf 100644 --- a/go/client.go +++ b/go/client.go @@ -502,6 +502,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.MCPServers = config.MCPServers req.EnvValueMode = "direct" req.CustomAgents = config.CustomAgents + req.Agent = config.Agent req.SkillDirectories = config.SkillDirectories req.DisabledSkills = config.DisabledSkills req.InfiniteSessions = config.InfiniteSessions @@ -616,6 +617,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.MCPServers = config.MCPServers req.EnvValueMode = "direct" req.CustomAgents = config.CustomAgents + req.Agent = config.Agent req.SkillDirectories = config.SkillDirectories req.DisabledSkills = config.DisabledSkills req.InfiniteSessions = config.InfiniteSessions diff --git a/go/client_test.go b/go/client_test.go index d740fd79b..76efe98ba 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -413,6 +413,60 @@ func TestResumeSessionRequest_ClientName(t *testing.T) { }) } +func TestCreateSessionRequest_Agent(t *testing.T) { + t.Run("includes agent in JSON when set", func(t *testing.T) { + req := createSessionRequest{Agent: "test-agent"} + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if m["agent"] != "test-agent" { + t.Errorf("Expected agent to be 'test-agent', got %v", m["agent"]) + } + }) + + t.Run("omits agent from JSON when empty", func(t *testing.T) { + req := createSessionRequest{} + data, _ := json.Marshal(req) + var m map[string]any + json.Unmarshal(data, &m) + if _, ok := m["agent"]; ok { + t.Error("Expected agent to be omitted when empty") + } + }) +} + +func TestResumeSessionRequest_Agent(t *testing.T) { + t.Run("includes agent in JSON when set", func(t *testing.T) { + req := resumeSessionRequest{SessionID: "s1", Agent: "test-agent"} + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if m["agent"] != "test-agent" { + t.Errorf("Expected agent to be 'test-agent', got %v", m["agent"]) + } + }) + + t.Run("omits agent from JSON when empty", func(t *testing.T) { + req := resumeSessionRequest{SessionID: "s1"} + data, _ := json.Marshal(req) + var m map[string]any + json.Unmarshal(data, &m) + if _, ok := m["agent"]; ok { + t.Error("Expected agent to be omitted when empty") + } + }) +} + func TestOverridesBuiltInTool(t *testing.T) { t.Run("OverridesBuiltInTool is serialized in tool definition", func(t *testing.T) { tool := Tool{ diff --git a/go/types.go b/go/types.go index d749de74a..7970b2fe0 100644 --- a/go/types.go +++ b/go/types.go @@ -384,6 +384,9 @@ type SessionConfig struct { MCPServers map[string]MCPServerConfig // CustomAgents configures custom agents for the session CustomAgents []CustomAgentConfig + // Agent is the name of the custom agent to activate when the session starts. + // Must match the Name of one of the agents in CustomAgents. + Agent string // SkillDirectories is a list of directories to load skills from SkillDirectories []string // DisabledSkills is a list of skill names to disable @@ -467,6 +470,9 @@ type ResumeSessionConfig struct { MCPServers map[string]MCPServerConfig // CustomAgents configures custom agents for the session CustomAgents []CustomAgentConfig + // Agent is the name of the custom agent to activate when the session starts. + // Must match the Name of one of the agents in CustomAgents. + Agent string // SkillDirectories is a list of directories to load skills from SkillDirectories []string // DisabledSkills is a list of skill names to disable @@ -652,6 +658,7 @@ type createSessionRequest struct { MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` EnvValueMode string `json:"envValueMode,omitempty"` CustomAgents []CustomAgentConfig `json:"customAgents,omitempty"` + Agent string `json:"agent,omitempty"` ConfigDir string `json:"configDir,omitempty"` SkillDirectories []string `json:"skillDirectories,omitempty"` DisabledSkills []string `json:"disabledSkills,omitempty"` @@ -685,6 +692,7 @@ type resumeSessionRequest struct { MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` EnvValueMode string `json:"envValueMode,omitempty"` CustomAgents []CustomAgentConfig `json:"customAgents,omitempty"` + Agent string `json:"agent,omitempty"` SkillDirectories []string `json:"skillDirectories,omitempty"` DisabledSkills []string `json:"disabledSkills,omitempty"` InfiniteSessions *InfiniteSessionConfig `json:"infiniteSessions,omitempty"` diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index de5f1856e..1108edaea 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -567,6 +567,7 @@ export class CopilotClient { mcpServers: config.mcpServers, envValueMode: "direct", customAgents: config.customAgents, + agent: config.agent, configDir: config.configDir, skillDirectories: config.skillDirectories, disabledSkills: config.disabledSkills, @@ -654,6 +655,7 @@ export class CopilotClient { mcpServers: config.mcpServers, envValueMode: "direct", customAgents: config.customAgents, + agent: config.agent, skillDirectories: config.skillDirectories, disabledSkills: config.disabledSkills, infiniteSessions: config.infiniteSessions, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 7eef94097..acda50fef 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -725,6 +725,13 @@ export interface SessionConfig { */ customAgents?: CustomAgentConfig[]; + /** + * Name of the custom agent to activate when the session starts. + * Must match the `name` of one of the agents in `customAgents`. + * Equivalent to calling `session.rpc.agent.select({ name })` after creation. + */ + agent?: string; + /** * Directories to load skills from. */ @@ -764,6 +771,7 @@ export type ResumeSessionConfig = Pick< | "configDir" | "mcpServers" | "customAgents" + | "agent" | "skillDirectories" | "disabledSkills" | "infiniteSessions" diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index b7dd34395..22f969998 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -336,4 +336,56 @@ describe("CopilotClient", () => { spy.mockRestore(); }); }); + + describe("agent parameter in session creation", () => { + it("forwards agent in session.create request", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi.spyOn((client as any).connection!, "sendRequest"); + await client.createSession({ + onPermissionRequest: approveAll, + customAgents: [ + { + name: "test-agent", + prompt: "You are a test agent.", + }, + ], + agent: "test-agent", + }); + + const payload = spy.mock.calls.find((c) => c[0] === "session.create")![1] as any; + expect(payload.agent).toBe("test-agent"); + expect(payload.customAgents).toEqual([expect.objectContaining({ name: "test-agent" })]); + }); + + it("forwards agent in session.resume request", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + await client.resumeSession(session.sessionId, { + onPermissionRequest: approveAll, + customAgents: [ + { + name: "test-agent", + prompt: "You are a test agent.", + }, + ], + agent: "test-agent", + }); + + const payload = spy.mock.calls.find((c) => c[0] === "session.resume")![1] as any; + expect(payload.agent).toBe("test-agent"); + spy.mockRestore(); + }); + }); }); diff --git a/python/copilot/client.py b/python/copilot/client.py index 7ea4e97a1..c29f35d12 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -569,6 +569,11 @@ async def create_session(self, config: SessionConfig) -> CopilotSession: self._convert_custom_agent_to_wire_format(agent) for agent in custom_agents ] + # Add agent selection if provided + agent = cfg.get("agent") + if agent: + payload["agent"] = agent + # Add config directory override if provided config_dir = cfg.get("config_dir") if config_dir: @@ -758,6 +763,11 @@ async def resume_session(self, session_id: str, config: ResumeSessionConfig) -> self._convert_custom_agent_to_wire_format(agent) for agent in custom_agents ] + # Add agent selection if provided + agent = cfg.get("agent") + if agent: + payload["agent"] = agent + # Add skill directories configuration if provided skill_directories = cfg.get("skill_directories") if skill_directories: diff --git a/python/copilot/types.py b/python/copilot/types.py index 6c484ce40..f094666ce 100644 --- a/python/copilot/types.py +++ b/python/copilot/types.py @@ -507,6 +507,9 @@ class SessionConfig(TypedDict, total=False): mcp_servers: dict[str, MCPServerConfig] # Custom agent configurations for the session custom_agents: list[CustomAgentConfig] + # Name of the custom agent to activate when the session starts. + # Must match the name of one of the agents in custom_agents. + agent: str # Override the default configuration directory location. # When specified, the session will use this directory for storing config and state. config_dir: str @@ -575,6 +578,9 @@ class ResumeSessionConfig(TypedDict, total=False): mcp_servers: dict[str, MCPServerConfig] # Custom agent configurations for the session custom_agents: list[CustomAgentConfig] + # Name of the custom agent to activate when the session starts. + # Must match the name of one of the agents in custom_agents. + agent: str # Directories to load skills from skill_directories: list[str] # List of skill names to disable diff --git a/python/test_client.py b/python/test_client.py index bcc249f30..ef068b7a1 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -265,6 +265,63 @@ async def mock_request(method, params): finally: await client.force_stop() + @pytest.mark.asyncio + async def test_create_session_forwards_agent(self): + client = CopilotClient({"cli_path": CLI_PATH}) + await client.start() + + try: + captured = {} + original_request = client._client.request + + async def mock_request(method, params): + captured[method] = params + return await original_request(method, params) + + client._client.request = mock_request + await client.create_session( + { + "agent": "test-agent", + "custom_agents": [{"name": "test-agent", "prompt": "You are a test agent."}], + "on_permission_request": PermissionHandler.approve_all, + } + ) + assert captured["session.create"]["agent"] == "test-agent" + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_resume_session_forwards_agent(self): + client = CopilotClient({"cli_path": CLI_PATH}) + await client.start() + + try: + session = await client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) + + captured = {} + original_request = client._client.request + + async def mock_request(method, params): + captured[method] = params + if method == "session.resume": + return {"sessionId": session.session_id} + return await original_request(method, params) + + client._client.request = mock_request + await client.resume_session( + session.session_id, + { + "agent": "test-agent", + "custom_agents": [{"name": "test-agent", "prompt": "You are a test agent."}], + "on_permission_request": PermissionHandler.approve_all, + }, + ) + assert captured["session.resume"]["agent"] == "test-agent" + finally: + await client.force_stop() + @pytest.mark.asyncio async def test_set_model_sends_correct_rpc(self): client = CopilotClient({"cli_path": CLI_PATH})