diff --git a/docs/features/custom-agents.md b/docs/features/custom-agents.md index de642e19..f9c1a373 100644 --- a/docs/features/custom-agents.md +++ b/docs/features/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/features/session-persistence.md b/docs/features/session-persistence.md index 7f3759df..59a5d9d5 100644 --- a/docs/features/session-persistence.md +++ b/docs/features/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/docs/getting-started.md b/docs/getting-started.md index de95a027..fe952182 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/dotnet/src/Client.cs b/dotnet/src/Client.cs index 8cad6b04..91b6353f 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 dbee05cf..52d870b8 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 8982c5d6..cc6e5ad5 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 a43530ad..3c1fb28c 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 d740fd79..76efe98b 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 d749de74..7970b2fe 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 de5f1856..1108edae 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 7eef9409..acda50fe 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 b7dd3439..22f96999 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 7ea4e97a..c29f35d1 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 6c484ce4..f094666c 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 bcc249f3..ef068b7a 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})