diff --git a/docs/features/custom-agents.md b/docs/features/custom-agents.md index f3c508922..0d27fe873 100644 --- a/docs/features/custom-agents.md +++ b/docs/features/custom-agents.md @@ -759,6 +759,154 @@ const session = await client.createSession({ > **Note:** When `tools` is `null` or omitted, the agent inherits access to all tools configured on the session. Use explicit tool lists to enforce the principle of least privilege. +## Agent-Exclusive Tools + +Use the `defaultAgent` property on the session configuration to hide specific tools from the default agent (the built-in agent that handles turns when no custom agent is selected). This forces the main agent to delegate to sub-agents when those tools' capabilities are needed, keeping the main agent's context clean. + +This is useful when: +- Certain tools generate large amounts of context that would overwhelm the main agent +- You want the main agent to act as an orchestrator, delegating heavy work to specialized sub-agents +- You need strict separation between orchestration and execution + +
+Node.js / TypeScript + +```typescript +import { CopilotClient, defineTool, approveAll } from "@github/copilot-sdk"; +import { z } from "zod"; + +const heavyContextTool = defineTool("analyze-codebase", { + description: "Performs deep analysis of the codebase, generating extensive context", + parameters: z.object({ query: z.string() }), + handler: async ({ query }) => { + // ... expensive analysis that returns lots of data + return { analysis: "..." }; + }, +}); + +const session = await client.createSession({ + tools: [heavyContextTool], + defaultAgent: { + excludedTools: ["analyze-codebase"], + }, + customAgents: [ + { + name: "researcher", + description: "Deep codebase analysis agent with access to heavy-context tools", + tools: ["analyze-codebase"], + prompt: "You perform thorough codebase analysis using the analyze-codebase tool.", + }, + ], +}); +``` + +
+ +
+Python + +```python +from copilot import CopilotClient +from copilot.tools import Tool + +heavy_tool = Tool( + name="analyze-codebase", + description="Performs deep analysis of the codebase", + handler=analyze_handler, + parameters={"type": "object", "properties": {"query": {"type": "string"}}}, +) + +session = await client.create_session( + tools=[heavy_tool], + default_agent={"excluded_tools": ["analyze-codebase"]}, + custom_agents=[ + { + "name": "researcher", + "description": "Deep codebase analysis agent", + "tools": ["analyze-codebase"], + "prompt": "You perform thorough codebase analysis.", + }, + ], + on_permission_request=approve_all, +) +``` + +
+ +
+Go + + +```go +session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Tools: []copilot.Tool{heavyTool}, + DefaultAgent: &copilot.DefaultAgentConfig{ + ExcludedTools: []string{"analyze-codebase"}, + }, + CustomAgents: []copilot.CustomAgentConfig{ + { + Name: "researcher", + Description: "Deep codebase analysis agent", + Tools: []string{"analyze-codebase"}, + Prompt: "You perform thorough codebase analysis.", + }, + }, +}) +``` + +
+ +
+C# / .NET + + +```csharp +var session = await client.CreateSessionAsync(new SessionConfig +{ + Tools = [analyzeCodebaseTool], + DefaultAgent = new DefaultAgentConfig + { + ExcludedTools = ["analyze-codebase"], + }, + CustomAgents = + [ + new CustomAgentConfig + { + Name = "researcher", + Description = "Deep codebase analysis agent", + Tools = ["analyze-codebase"], + Prompt = "You perform thorough codebase analysis.", + }, + ], +}); +``` + +
+ +### How It Works + +Tools listed in `defaultAgent.excludedTools`: + +1. **Are registered** — their handlers are available for execution +2. **Are hidden** from the main agent's tool list — the LLM won't see or call them directly +3. **Remain available** to any custom sub-agent that includes them in its `tools` array + +### Interaction with Other Tool Filters + +`defaultAgent.excludedTools` is orthogonal to the session-level `availableTools` and `excludedTools`: + +| Filter | Scope | Effect | +|--------|-------|--------| +| `availableTools` | Session-wide | Allowlist — only these tools exist for anyone | +| `excludedTools` | Session-wide | Blocklist — these tools are blocked for everyone | +| `defaultAgent.excludedTools` | Main agent only | These tools are hidden from the main agent but available to sub-agents | + +Precedence: +1. Session-level `availableTools`/`excludedTools` are applied first (globally) +2. `defaultAgent.excludedTools` is applied on top, further restricting the main agent only + +> **Note:** If a tool is in both `excludedTools` (session-level) and `defaultAgent.excludedTools`, the session-level exclusion takes precedence — the tool is unavailable to everyone. + ## Attaching MCP Servers to Agents Each custom agent can have its own MCP (Model Context Protocol) servers, giving it access to specialized data sources: diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 668d090f5..3941abbec 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -501,6 +501,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance config.McpServers, "direct", config.CustomAgents, + config.DefaultAgent, config.Agent, config.ConfigDir, config.EnableConfigDiscovery, @@ -627,6 +628,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config.McpServers, "direct", config.CustomAgents, + config.DefaultAgent, config.Agent, config.SkillDirectories, config.DisabledSkills, @@ -1642,6 +1644,7 @@ internal record CreateSessionRequest( IDictionary? McpServers, string? EnvValueMode, IList? CustomAgents, + DefaultAgentConfig? DefaultAgent, string? Agent, string? ConfigDir, bool? EnableConfigDiscovery, @@ -1698,6 +1701,7 @@ internal record ResumeSessionRequest( IDictionary? McpServers, string? EnvValueMode, IList? CustomAgents, + DefaultAgentConfig? DefaultAgent, string? Agent, IList? SkillDirectories, IList? DisabledSkills, diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index fd42d0c27..131362055 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -1656,6 +1656,21 @@ public class CustomAgentConfig public IList? Skills { get; set; } } +/// +/// Configuration for the default agent (the built-in agent that handles turns when no custom agent is selected). +/// Use to hide specific tools from the default agent +/// while keeping them available to custom sub-agents. +/// +public class DefaultAgentConfig +{ + /// + /// List of tool names to exclude from the default agent. + /// These tools remain available to custom sub-agents that reference them + /// in their list. + /// + public IList? ExcludedTools { get; set; } +} + /// /// Configuration for infinite sessions with automatic context compaction and workspace persistence. /// When enabled, sessions automatically manage context window limits through background compaction @@ -1709,6 +1724,7 @@ protected SessionConfig(SessionConfig? other) Commands = other.Commands is not null ? [.. other.Commands] : null; ConfigDir = other.ConfigDir; CustomAgents = other.CustomAgents is not null ? [.. other.CustomAgents] : null; + DefaultAgent = other.DefaultAgent; Agent = other.Agent; DisabledSkills = other.DisabledSkills is not null ? [.. other.DisabledSkills] : null; EnableConfigDiscovery = other.EnableConfigDiscovery; @@ -1871,6 +1887,13 @@ protected SessionConfig(SessionConfig? other) /// public IList? CustomAgents { get; set; } + /// + /// Configuration for the default agent (the built-in agent that handles turns when no custom agent is selected). + /// Use to hide specific tools from the default agent + /// while keeping them available to custom sub-agents. + /// + public DefaultAgentConfig? DefaultAgent { get; set; } + /// /// Name of the custom agent to activate when the session starts. /// Must match the of one of the agents in . @@ -1950,6 +1973,7 @@ protected ResumeSessionConfig(ResumeSessionConfig? other) Commands = other.Commands is not null ? [.. other.Commands] : null; ConfigDir = other.ConfigDir; CustomAgents = other.CustomAgents is not null ? [.. other.CustomAgents] : null; + DefaultAgent = other.DefaultAgent; Agent = other.Agent; DisabledSkills = other.DisabledSkills is not null ? [.. other.DisabledSkills] : null; DisableResume = other.DisableResume; @@ -2117,6 +2141,13 @@ protected ResumeSessionConfig(ResumeSessionConfig? other) /// public IList? CustomAgents { get; set; } + /// + /// Configuration for the default agent (the built-in agent that handles turns when no custom agent is selected). + /// Use to hide specific tools from the default agent + /// while keeping them available to custom sub-agents. + /// + public DefaultAgentConfig? DefaultAgent { get; set; } + /// /// Name of the custom agent to activate when the session starts. /// Must match the of one of the agents in . diff --git a/dotnet/test/CloneTests.cs b/dotnet/test/CloneTests.cs index cc36162ff..5c326dcc4 100644 --- a/dotnet/test/CloneTests.cs +++ b/dotnet/test/CloneTests.cs @@ -90,6 +90,7 @@ public void SessionConfig_Clone_CopiesAllProperties() McpServers = new Dictionary { ["server1"] = new McpStdioServerConfig { Command = "echo" } }, CustomAgents = [new CustomAgentConfig { Name = "agent1" }], Agent = "agent1", + DefaultAgent = new DefaultAgentConfig { ExcludedTools = ["hidden-tool"] }, SkillDirectories = ["/skills"], DisabledSkills = ["skill1"], }; @@ -109,6 +110,7 @@ public void SessionConfig_Clone_CopiesAllProperties() 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.DefaultAgent!.ExcludedTools, clone.DefaultAgent!.ExcludedTools); Assert.Equal(original.SkillDirectories, clone.SkillDirectories); Assert.Equal(original.DisabledSkills, clone.DisabledSkills); } @@ -245,6 +247,7 @@ public void Clone_WithNullCollections_ReturnsNullCollections() Assert.Null(clone.SkillDirectories); Assert.Null(clone.DisabledSkills); Assert.Null(clone.Tools); + Assert.Null(clone.DefaultAgent); Assert.True(clone.IncludeSubAgentStreamingEvents); } diff --git a/dotnet/test/SessionTests.cs b/dotnet/test/SessionTests.cs index 59c11a84f..241698516 100644 --- a/dotnet/test/SessionTests.cs +++ b/dotnet/test/SessionTests.cs @@ -162,6 +162,35 @@ public async Task Should_Create_A_Session_With_ExcludedTools() Assert.Contains("grep", toolNames); } + [Fact] + public async Task Should_Create_A_Session_With_DefaultAgent_ExcludedTools() + { + var session = await CreateSessionAsync(new SessionConfig + { + Tools = + [ + AIFunctionFactory.Create( + (string input) => "SECRET", + "secret_tool", + "A secret tool hidden from the default agent"), + ], + DefaultAgent = new DefaultAgentConfig + { + ExcludedTools = ["secret_tool"], + }, + }); + + await session.SendAsync(new MessageOptions { Prompt = "What is 1+1?" }); + await TestHelper.GetFinalAssistantMessageAsync(session); + + // The real assertion: verify the runtime excluded the tool from the CAPI request + var traffic = await Ctx.GetExchangesAsync(); + Assert.NotEmpty(traffic); + + var toolNames = GetToolNames(traffic[0]); + Assert.DoesNotContain("secret_tool", toolNames); + } + [Fact] public async Task Should_Create_Session_With_Custom_Tool() { diff --git a/go/client.go b/go/client.go index 37e572dc8..74e4839be 100644 --- a/go/client.go +++ b/go/client.go @@ -592,6 +592,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.MCPServers = config.MCPServers req.EnvValueMode = "direct" req.CustomAgents = config.CustomAgents + req.DefaultAgent = config.DefaultAgent req.Agent = config.Agent req.SkillDirectories = config.SkillDirectories req.DisabledSkills = config.DisabledSkills @@ -776,6 +777,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.MCPServers = config.MCPServers req.EnvValueMode = "direct" req.CustomAgents = config.CustomAgents + req.DefaultAgent = config.DefaultAgent req.Agent = config.Agent req.SkillDirectories = config.SkillDirectories req.DisabledSkills = config.DisabledSkills diff --git a/go/internal/e2e/session_test.go b/go/internal/e2e/session_test.go index 1fed130d3..96ab7a908 100644 --- a/go/internal/e2e/session_test.go +++ b/go/internal/e2e/session_test.go @@ -313,6 +313,57 @@ func TestSession(t *testing.T) { } }) + t.Run("should create a session with defaultAgent excludedTools", func(t *testing.T) { + ctx.ConfigureForTest(t) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Tools: []copilot.Tool{ + { + Name: "secret_tool", + Description: "A secret tool hidden from the default agent", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{"input": map[string]any{"type": "string"}}, + }, + Handler: func(invocation copilot.ToolInvocation) (copilot.ToolResult, error) { + return copilot.ToolResult{TextResultForLLM: "SECRET", ResultType: "success"}, nil + }, + }, + }, + DefaultAgent: &copilot.DefaultAgentConfig{ + ExcludedTools: []string{"secret_tool"}, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + _, err = session.Send(t.Context(), copilot.MessageOptions{Prompt: "What is 1+1?"}) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + _, err = testharness.GetFinalAssistantMessage(t.Context(), session) + if err != nil { + t.Fatalf("Failed to get assistant message: %v", err) + } + + // The real assertion: verify the runtime excluded the tool from the CAPI request + traffic, err := ctx.GetExchanges() + if err != nil { + t.Fatalf("Failed to get exchanges: %v", err) + } + if len(traffic) == 0 { + t.Fatal("Expected at least one exchange") + } + + toolNames := getToolNames(traffic[0]) + if contains(toolNames, "secret_tool") { + t.Errorf("Expected 'secret_tool' to be excluded from default agent, got %v", toolNames) + } + }) + t.Run("should create session with custom tool", func(t *testing.T) { ctx.ConfigureForTest(t) diff --git a/go/types.go b/go/types.go index aa4fafc94..e9f78e276 100644 --- a/go/types.go +++ b/go/types.go @@ -454,6 +454,15 @@ type CustomAgentConfig struct { Skills []string `json:"skills,omitempty"` } +// DefaultAgentConfig configures the default agent (the built-in agent that handles turns when no custom agent is selected). +// Use ExcludedTools to hide specific tools from the default agent while keeping +// them available to custom sub-agents. +type DefaultAgentConfig struct { + // ExcludedTools is a list of tool names to exclude from the default agent. + // These tools remain available to custom sub-agents that reference them in their Tools list. + ExcludedTools []string `json:"excludedTools,omitempty"` +} + // InfiniteSessionConfig configures infinite sessions with automatic context compaction // and workspace persistence. When enabled, sessions automatically manage context window // limits through background compaction and persist state to a workspace directory. @@ -543,6 +552,9 @@ type SessionConfig struct { MCPServers map[string]MCPServerConfig // CustomAgents configures custom agents for the session CustomAgents []CustomAgentConfig + // DefaultAgent configures the default agent (the built-in agent that handles turns when no custom agent is selected). + // Use ExcludedTools to hide tools from the default agent while keeping them available to sub-agents. + DefaultAgent *DefaultAgentConfig // 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 @@ -758,6 +770,8 @@ type ResumeSessionConfig struct { MCPServers map[string]MCPServerConfig // CustomAgents configures custom agents for the session CustomAgents []CustomAgentConfig + // DefaultAgent configures the default agent (the built-in agent that handles turns when no custom agent is selected). + DefaultAgent *DefaultAgentConfig // 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 @@ -970,6 +984,7 @@ type createSessionRequest struct { MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` EnvValueMode string `json:"envValueMode,omitempty"` CustomAgents []CustomAgentConfig `json:"customAgents,omitempty"` + DefaultAgent *DefaultAgentConfig `json:"defaultAgent,omitempty"` Agent string `json:"agent,omitempty"` ConfigDir string `json:"configDir,omitempty"` EnableConfigDiscovery *bool `json:"enableConfigDiscovery,omitempty"` @@ -1019,6 +1034,7 @@ type resumeSessionRequest struct { MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` EnvValueMode string `json:"envValueMode,omitempty"` CustomAgents []CustomAgentConfig `json:"customAgents,omitempty"` + DefaultAgent *DefaultAgentConfig `json:"defaultAgent,omitempty"` Agent string `json:"agent,omitempty"` SkillDirectories []string `json:"skillDirectories,omitempty"` DisabledSkills []string `json:"disabledSkills,omitempty"` diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index c8c137c3d..f4aa1e44f 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -752,6 +752,7 @@ export class CopilotClient { mcpServers: config.mcpServers, envValueMode: "direct", customAgents: config.customAgents, + defaultAgent: config.defaultAgent, agent: config.agent, configDir: config.configDir, enableConfigDiscovery: config.enableConfigDiscovery, @@ -893,6 +894,7 @@ export class CopilotClient { mcpServers: config.mcpServers, envValueMode: "direct", customAgents: config.customAgents, + defaultAgent: config.defaultAgent, agent: config.agent, skillDirectories: config.skillDirectories, disabledSkills: config.disabledSkills, diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index e2942998a..503d0942d 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -38,6 +38,7 @@ export type { MCPStdioServerConfig, MCPHTTPServerConfig, MCPServerConfig, + DefaultAgentConfig, MessageOptions, ModelBilling, ModelCapabilities, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 9b2df4193..a8c644341 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1114,6 +1114,21 @@ export interface CustomAgentConfig { skills?: string[]; } +/** + * Configuration for the default agent (the built-in agent that handles + * turns when no custom agent is selected). + * Use this to control tool visibility for the default agent independently of custom sub-agents. + */ +export interface DefaultAgentConfig { + /** + * List of tool names to exclude from the default agent. + * These tools remain available to custom sub-agents that reference them in their `tools` array. + * Use this to register tools that should only be accessed via delegation to sub-agents, + * keeping the default agent's context clean. + */ + excludedTools?: string[]; +} + /** * Configuration for infinite sessions with automatic context compaction and workspace persistence. * When enabled, sessions automatically manage context window limits through background compaction @@ -1292,6 +1307,14 @@ export interface SessionConfig { */ customAgents?: CustomAgentConfig[]; + /** + * Configuration for the default agent (the built-in agent that handles + * turns when no custom agent is selected). + * Use `excludedTools` to hide specific tools from the default agent while keeping + * them available to custom sub-agents. + */ + defaultAgent?: DefaultAgentConfig; + /** * Name of the custom agent to activate when the session starts. * Must match the `name` of one of the agents in `customAgents`. @@ -1360,6 +1383,7 @@ export type ResumeSessionConfig = Pick< | "enableConfigDiscovery" | "mcpServers" | "customAgents" + | "defaultAgent" | "agent" | "skillDirectories" | "disabledSkills" diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 1c0eceb65..4ea74b576 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -227,6 +227,45 @@ describe("CopilotClient", () => { spy.mockRestore(); }); + it("forwards defaultAgent 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({ + defaultAgent: { excludedTools: ["heavy-tool"] }, + onPermissionRequest: approveAll, + }); + + expect(spy).toHaveBeenCalledWith( + "session.create", + expect.objectContaining({ + defaultAgent: { excludedTools: ["heavy-tool"] }, + }) + ); + }); + + it("forwards defaultAgent 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"); + await client.resumeSession(session.sessionId, { + defaultAgent: { excludedTools: ["heavy-tool"] }, + onPermissionRequest: approveAll, + }); + + expect(spy).toHaveBeenCalledWith( + "session.resume", + expect.objectContaining({ + defaultAgent: { excludedTools: ["heavy-tool"] }, + }) + ); + }); + it("does not request permissions on session.resume when using the default joinSession handler", async () => { const client = new CopilotClient(); await client.start(); diff --git a/nodejs/test/e2e/mcp_and_agents.test.ts b/nodejs/test/e2e/mcp_and_agents.test.ts index 59e6d498b..aa580cdee 100644 --- a/nodejs/test/e2e/mcp_and_agents.test.ts +++ b/nodejs/test/e2e/mcp_and_agents.test.ts @@ -5,8 +5,9 @@ import { dirname, resolve } from "path"; import { fileURLToPath } from "url"; import { describe, expect, it } from "vitest"; +import { z } from "zod"; import type { CustomAgentConfig, MCPStdioServerConfig, MCPServerConfig } from "../../src/index.js"; -import { approveAll } from "../../src/index.js"; +import { approveAll, defineTool } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; const __filename = fileURLToPath(import.meta.url); @@ -14,7 +15,7 @@ const __dirname = dirname(__filename); const TEST_MCP_SERVER = resolve(__dirname, "../../../test/harness/test-mcp-server.mjs"); describe("MCP Servers and Custom Agents", async () => { - const { copilotClient: client } = await createSdkTestContext(); + const { copilotClient: client, openAiEndpoint } = await createSdkTestContext(); describe("MCP Servers", () => { it("should accept MCP server configuration on session create", async () => { @@ -296,4 +297,72 @@ describe("MCP Servers and Custom Agents", async () => { await session.disconnect(); }); }); + + describe("Default Agent Tool Exclusion", () => { + it("should hide excluded tools from default agent", async () => { + const secretTool = defineTool("secret_tool", { + description: "A secret tool hidden from the default agent", + parameters: z.object({ + input: z.string().describe("Input to process"), + }), + handler: ({ input }) => `SECRET:${input}`, + }); + + const session = await client.createSession({ + onPermissionRequest: approveAll, + tools: [secretTool], + defaultAgent: { + excludedTools: ["secret_tool"], + }, + }); + + // Ask about the tool — the default agent should not see it + const message = await session.sendAndWait({ + prompt: "Do you have access to a tool called secret_tool? Answer yes or no.", + }); + + // Sanity-check the replayed response (not the actual exclusion assertion) + expect(message?.data.content?.toLowerCase()).toContain("no"); + + // The real assertion: verify the runtime excluded the tool from the CAPI request + const exchanges = await openAiEndpoint.getExchanges(); + const toolNames = exchanges.flatMap((e) => + (e.request.tools ?? []).map((t) => ("function" in t ? t.function.name : "")) + ); + expect(toolNames).not.toContain("secret_tool"); + + await session.disconnect(); + }); + + it("should accept defaultAgent configuration on session resume", async () => { + const session1 = await client.createSession({ onPermissionRequest: approveAll }); + const sessionId = session1.sessionId; + await session1.sendAndWait({ prompt: "What is 3+3?" }); + + const secretTool = defineTool("secret_tool", { + description: "A secret tool hidden from the default agent", + parameters: z.object({ + input: z.string().describe("Input to process"), + }), + handler: ({ input }) => `SECRET:${input}`, + }); + + const session2 = await client.resumeSession(sessionId, { + onPermissionRequest: approveAll, + tools: [secretTool], + defaultAgent: { + excludedTools: ["secret_tool"], + }, + }); + + expect(session2.sessionId).toBe(sessionId); + + const message = await session2.sendAndWait({ + prompt: "What is 4+4?", + }); + expect(message?.data.content).toContain("8"); + + await session2.disconnect(); + }); + }); }); diff --git a/python/copilot/client.py b/python/copilot/client.py index 4c1186f23..09d970f4b 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -47,6 +47,7 @@ CopilotSession, CreateSessionFsHandler, CustomAgentConfig, + DefaultAgentConfig, ElicitationHandler, InfiniteSessionConfig, MCPServerConfig, @@ -1199,6 +1200,7 @@ async def create_session( include_sub_agent_streaming_events: bool | None = None, mcp_servers: dict[str, MCPServerConfig] | None = None, custom_agents: list[CustomAgentConfig] | None = None, + default_agent: DefaultAgentConfig | dict[str, Any] | None = None, agent: str | None = None, config_dir: str | None = None, enable_config_discovery: bool | None = None, @@ -1241,6 +1243,8 @@ async def create_session( ``subagent.*`` lifecycle events are forwarded. Defaults to True. mcp_servers: MCP server configurations. custom_agents: Custom agent configurations. + default_agent: Configuration for the default agent, + including tool visibility controls. agent: Agent to use for the session. config_dir: Override for the configuration directory. enable_config_discovery: When True, automatically discovers MCP server @@ -1373,6 +1377,10 @@ async def create_session( self._convert_custom_agent_to_wire_format(agent) for agent in custom_agents ] + # Add default agent configuration if provided + if default_agent: + payload["defaultAgent"] = self._convert_default_agent_to_wire_format(default_agent) + # Add agent selection if provided if agent: payload["agent"] = agent @@ -1477,6 +1485,7 @@ async def resume_session( include_sub_agent_streaming_events: bool | None = None, mcp_servers: dict[str, MCPServerConfig] | None = None, custom_agents: list[CustomAgentConfig] | None = None, + default_agent: DefaultAgentConfig | dict[str, Any] | None = None, agent: str | None = None, config_dir: str | None = None, enable_config_discovery: bool | None = None, @@ -1519,6 +1528,8 @@ async def resume_session( ``subagent.*`` lifecycle events are forwarded. Defaults to True. mcp_servers: MCP server configurations. custom_agents: Custom agent configurations. + default_agent: Configuration for the default agent, + including tool visibility controls. agent: Agent to use for the session. config_dir: Override for the configuration directory. enable_config_discovery: When True, automatically discovers MCP server @@ -1645,6 +1656,10 @@ async def resume_session( self._convert_custom_agent_to_wire_format(a) for a in custom_agents ] + # Add default agent configuration if provided + if default_agent: + payload["defaultAgent"] = self._convert_default_agent_to_wire_format(default_agent) + if agent: payload["agent"] = agent if skill_directories: @@ -2188,6 +2203,23 @@ def _convert_custom_agent_to_wire_format( wire_agent["skills"] = agent["skills"] return wire_agent + def _convert_default_agent_to_wire_format( + self, config: DefaultAgentConfig | dict[str, Any] + ) -> dict[str, Any]: + """ + Convert default agent config from snake_case to camelCase wire format. + + Args: + config: The default agent configuration in snake_case format. + + Returns: + The default agent configuration in camelCase wire format. + """ + wire: dict[str, Any] = {} + if "excluded_tools" in config: + wire["excludedTools"] = config["excluded_tools"] + return wire + async def _start_cli_server(self) -> None: """ Start the CLI server process. diff --git a/python/copilot/session.py b/python/copilot/session.py index ac771923a..148b1aa63 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -781,6 +781,18 @@ class CustomAgentConfig(TypedDict, total=False): skills: NotRequired[list[str]] +class DefaultAgentConfig(TypedDict, total=False): + """Configuration for the default agent. + + The default agent is the built-in agent that handles turns + when no custom agent is selected. + """ + + # List of tool names to exclude from the default agent. + # These tools remain available to custom sub-agents that reference them. + excluded_tools: list[str] + + class InfiniteSessionConfig(TypedDict, total=False): """ Configuration for infinite sessions with automatic context compaction @@ -870,6 +882,10 @@ class SessionConfig(TypedDict, total=False): mcp_servers: dict[str, MCPServerConfig] # Custom agent configurations for the session custom_agents: list[CustomAgentConfig] + # Configuration for the default agent. + # Use excluded_tools to hide tools from the default agent + # while keeping them available to sub-agents. + default_agent: DefaultAgentConfig # 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 @@ -938,6 +954,8 @@ class ResumeSessionConfig(TypedDict, total=False): mcp_servers: dict[str, MCPServerConfig] # Custom agent configurations for the session custom_agents: list[CustomAgentConfig] + # Configuration for the default agent. + default_agent: DefaultAgentConfig # 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 diff --git a/python/e2e/test_session.py b/python/e2e/test_session.py index 621062e4e..9e8440b9d 100644 --- a/python/e2e/test_session.py +++ b/python/e2e/test_session.py @@ -146,6 +146,34 @@ async def test_should_create_a_session_with_excludedTools(self, ctx: E2ETestCont assert "grep" in tool_names assert "view" not in tool_names + async def test_should_create_a_session_with_defaultAgent_excludedTools( + self, ctx: E2ETestContext + ): + secret_tool = Tool( + name="secret_tool", + description="A secret tool hidden from the default agent", + handler=lambda args: "SECRET", + parameters={ + "type": "object", + "properties": {"input": {"type": "string"}}, + }, + ) + + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + tools=[secret_tool], + default_agent={"excluded_tools": ["secret_tool"]}, + ) + + await session.send("What is 1+1?") + await get_final_assistant_message(session) + + # The real assertion: verify the runtime excluded the tool from the CAPI request + traffic = await ctx.get_exchanges() + tools = traffic[0]["request"]["tools"] + tool_names = [t["function"]["name"] for t in tools] + assert "secret_tool" not in tool_names + # TODO: This test shows there's a race condition inside client.ts. If createSession # is called concurrently and autoStart is on, it may start multiple child processes. # This needs to be fixed. Right now it manifests as being unable to delete the temp diff --git a/test/scenarios/tools/custom-agents/README.md b/test/scenarios/tools/custom-agents/README.md index 41bb78c9e..391345454 100644 --- a/test/scenarios/tools/custom-agents/README.md +++ b/test/scenarios/tools/custom-agents/README.md @@ -1,26 +1,30 @@ # Config Sample: Custom Agents -Demonstrates configuring the Copilot SDK with **custom agent definitions** that restrict which tools an agent can use. This validates: +Demonstrates configuring the Copilot SDK with **custom agent definitions** that restrict which tools an agent can use, and **agent-exclusive tools** that are hidden from the main agent. This validates: 1. **Agent definition** — The `customAgents` session config accepts agent definitions with name, description, tool lists, and custom prompts. 2. **Tool scoping** — Each custom agent can be restricted to a subset of available tools (e.g. read-only tools like `grep`, `glob`, `view`). -3. **Agent awareness** — The model recognizes and can describe the configured custom agents. +3. **Agent-exclusive tools** — The `defaultAgent.excludedTools` option hides tools from the main agent while keeping them available to sub-agents. +4. **Agent awareness** — The model recognizes and can describe the configured custom agents. ## What Each Sample Does -1. Creates a session with a `customAgents` array containing a "researcher" agent -2. The researcher agent is scoped to read-only tools: `grep`, `glob`, `view` -3. Sends: _"What custom agents are available? Describe the researcher agent and its capabilities."_ -4. Prints the response — which should describe the researcher agent and its tool restrictions +1. Creates a session with a custom `analyze-codebase` tool and a `customAgents` array containing a "researcher" agent +2. Uses `defaultAgent.excludedTools` to hide `analyze-codebase` from the main agent +3. The researcher agent is scoped to read-only tools plus `analyze-codebase`: `grep`, `glob`, `view`, `analyze-codebase` +4. Sends: _"What custom agents are available? Describe the researcher agent and its capabilities."_ +5. Prints the response — which should describe the researcher agent and its tool restrictions ## Configuration | Option | Value | Effect | |--------|-------|--------| +| `tools` | `[analyze-codebase]` | Registers custom tool at session level | +| `defaultAgent.excludedTools` | `["analyze-codebase"]` | Hides tool from main agent | | `customAgents[0].name` | `"researcher"` | Internal identifier for the agent | | `customAgents[0].displayName` | `"Research Agent"` | Human-readable name | | `customAgents[0].description` | Custom text | Describes agent purpose | -| `customAgents[0].tools` | `["grep", "glob", "view"]` | Restricts agent to read-only tools | +| `customAgents[0].tools` | `["grep", "glob", "view", "analyze-codebase"]` | Restricts agent to read-only tools + analysis | | `customAgents[0].prompt` | Custom text | Sets agent behavior instructions | ## Run diff --git a/test/scenarios/tools/custom-agents/csharp/Program.cs b/test/scenarios/tools/custom-agents/csharp/Program.cs index c5c6525f1..d3c068ade 100644 --- a/test/scenarios/tools/custom-agents/csharp/Program.cs +++ b/test/scenarios/tools/custom-agents/csharp/Program.cs @@ -1,4 +1,5 @@ using GitHub.Copilot.SDK; +using Microsoft.Extensions.AI; var cliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"); @@ -12,9 +13,22 @@ try { + var analyzeCodebase = AIFunctionFactory.Create( + (string query) => $"Analysis result for: {query}", + new AIFunctionFactoryOptions + { + Name = "analyze-codebase", + Description = "Performs deep analysis of the codebase", + }); + await using var session = await client.CreateSessionAsync(new SessionConfig { Model = "claude-haiku-4.5", + Tools = [analyzeCodebase], + DefaultAgent = new DefaultAgentConfig + { + ExcludedTools = ["analyze-codebase"], + }, CustomAgents = [ new CustomAgentConfig @@ -22,7 +36,7 @@ Name = "researcher", DisplayName = "Research Agent", Description = "A research agent that can only read and search files, not modify them", - Tools = ["grep", "glob", "view"], + Tools = ["grep", "glob", "view", "analyze-codebase"], Prompt = "You are a research assistant. You can search and read files but cannot modify anything. When asked about your capabilities, list the tools you have access to.", }, ], diff --git a/test/scenarios/tools/custom-agents/go/main.go b/test/scenarios/tools/custom-agents/go/main.go index d1769ff2b..1e6ada739 100644 --- a/test/scenarios/tools/custom-agents/go/main.go +++ b/test/scenarios/tools/custom-agents/go/main.go @@ -20,14 +20,29 @@ func main() { } defer client.Stop() + type AnalyzeParams struct { + Query string `json:"query" jsonschema:"the analysis query"` + } + + analyzeCodebase := copilot.DefineTool("analyze-codebase", + "Performs deep analysis of the codebase", + func(params AnalyzeParams, inv copilot.ToolInvocation) (string, error) { + return fmt.Sprintf("Analysis result for: %s", params.Query), nil + }, + ) + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ Model: "claude-haiku-4.5", + Tools: []copilot.Tool{analyzeCodebase}, + DefaultAgent: &copilot.DefaultAgentConfig{ + ExcludedTools: []string{"analyze-codebase"}, + }, CustomAgents: []copilot.CustomAgentConfig{ { Name: "researcher", DisplayName: "Research Agent", Description: "A research agent that can only read and search files, not modify them", - Tools: []string{"grep", "glob", "view"}, + Tools: []string{"grep", "glob", "view", "analyze-codebase"}, Prompt: "You are a research assistant. You can search and read files but cannot modify anything. When asked about your capabilities, list the tools you have access to.", }, }, diff --git a/test/scenarios/tools/custom-agents/python/main.py b/test/scenarios/tools/custom-agents/python/main.py index d4c45950f..bf6e3978c 100644 --- a/test/scenarios/tools/custom-agents/python/main.py +++ b/test/scenarios/tools/custom-agents/python/main.py @@ -2,6 +2,11 @@ import os from copilot import CopilotClient from copilot.client import SubprocessConfig +from copilot.tools import Tool + + +async def analyze_handler(args): + return f"Analysis result for: {args.get('query', '')}" async def main(): @@ -12,18 +17,29 @@ async def main(): try: session = await client.create_session( - { - "model": "claude-haiku-4.5", - "custom_agents": [ - { - "name": "researcher", - "display_name": "Research Agent", - "description": "A research agent that can only read and search files, not modify them", - "tools": ["grep", "glob", "view"], - "prompt": "You are a research assistant. You can search and read files but cannot modify anything. When asked about your capabilities, list the tools you have access to.", + model="claude-haiku-4.5", + tools=[ + Tool( + name="analyze-codebase", + description="Performs deep analysis of the codebase", + handler=analyze_handler, + parameters={ + "type": "object", + "properties": {"query": {"type": "string"}}, }, - ], - } + ), + ], + default_agent={"excluded_tools": ["analyze-codebase"]}, + custom_agents=[ + { + "name": "researcher", + "display_name": "Research Agent", + "description": "A research agent that can only read and search files, not modify them", + "tools": ["grep", "glob", "view", "analyze-codebase"], + "prompt": "You are a research assistant. You can search and read files but cannot modify anything. When asked about your capabilities, list the tools you have access to.", + }, + ], + on_permission_request=lambda _: {"action": "allow"}, ) response = await session.send_and_wait( diff --git a/test/scenarios/tools/custom-agents/typescript/src/index.ts b/test/scenarios/tools/custom-agents/typescript/src/index.ts index f6e163256..ffb0bd827 100644 --- a/test/scenarios/tools/custom-agents/typescript/src/index.ts +++ b/test/scenarios/tools/custom-agents/typescript/src/index.ts @@ -1,4 +1,13 @@ -import { CopilotClient } from "@github/copilot-sdk"; +import { CopilotClient, defineTool } from "@github/copilot-sdk"; +import { z } from "zod"; + +const analyzeCodebase = defineTool("analyze-codebase", { + description: "Performs deep analysis of the codebase, generating extensive context", + parameters: z.object({ query: z.string().describe("The analysis query") }), + handler: async ({ query }) => { + return `Analysis result for: ${query}`; + }, +}); async function main() { const client = new CopilotClient({ @@ -9,12 +18,16 @@ async function main() { try { const session = await client.createSession({ model: "claude-haiku-4.5", + tools: [analyzeCodebase], + defaultAgent: { + excludedTools: ["analyze-codebase"], + }, customAgents: [ { name: "researcher", displayName: "Research Agent", description: "A research agent that can only read and search files, not modify them", - tools: ["grep", "glob", "view"], + tools: ["grep", "glob", "view", "analyze-codebase"], prompt: "You are a research assistant. You can search and read files but cannot modify anything. When asked about your capabilities, list the tools you have access to.", }, ], diff --git a/test/snapshots/mcp_and_agents/should_accept_defaultagent_configuration_on_session_resume.yaml b/test/snapshots/mcp_and_agents/should_accept_defaultagent_configuration_on_session_resume.yaml new file mode 100644 index 000000000..65fe6664e --- /dev/null +++ b/test/snapshots/mcp_and_agents/should_accept_defaultagent_configuration_on_session_resume.yaml @@ -0,0 +1,14 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What is 3+3? + - role: assistant + content: 3 + 3 = 6 + - role: user + content: What is 4+4? + - role: assistant + content: 4 + 4 = 8 diff --git a/test/snapshots/mcp_and_agents/should_hide_excluded_tools_from_default_agent.yaml b/test/snapshots/mcp_and_agents/should_hide_excluded_tools_from_default_agent.yaml new file mode 100644 index 000000000..f5506bb18 --- /dev/null +++ b/test/snapshots/mcp_and_agents/should_hide_excluded_tools_from_default_agent.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Do you have access to a tool called secret_tool? Answer yes or no. + - role: assistant + content: No, I don't have access to a tool called secret_tool. diff --git a/test/snapshots/session/should_create_a_session_with_defaultagent_excludedtools.yaml b/test/snapshots/session/should_create_a_session_with_defaultagent_excludedtools.yaml new file mode 100644 index 000000000..250402101 --- /dev/null +++ b/test/snapshots/session/should_create_a_session_with_defaultagent_excludedtools.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What is 1+1? + - role: assistant + content: 1 + 1 = 2