From 5c977f193710aaed852df0b4bec430bfe0242c6a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 05:04:29 +0000 Subject: [PATCH 01/10] Initial plan From 94e9daa9fa05d32faa604f67e502313f78642a95 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 05:20:55 +0000 Subject: [PATCH 02/10] feat: Support overriding built-in tools (Issue #411) Auto-add user-registered tool names to excludedTools in session.create/resume RPC payloads so that SDK-registered tools override CLI built-in tools. - Node.js: mergeExcludedTools() helper + createSession/resumeSession updates - Python: inline merge logic in create_session/resume_session - Go: mergeExcludedTools() helper + CreateSession/ResumeSessionWithOptions updates - .NET: MergeExcludedTools() helper + CreateSessionAsync/ResumeSessionAsync updates - Tests added for all 4 SDKs - All 4 READMEs updated with "Overriding Built-in Tools" documentation Co-authored-by: patniko <26906478+patniko@users.noreply.github.com> --- dotnet/README.md | 13 ++++ dotnet/src/Client.cs | 12 ++- dotnet/src/GitHub.Copilot.SDK.csproj | 4 + dotnet/test/MergeExcludedToolsTests.cs | 79 +++++++++++++++++++ go/README.md | 11 +++ go/client.go | 27 ++++++- go/client_test.go | 36 +++++++++ nodejs/README.md | 12 +++ nodejs/src/client.ts | 17 ++++- nodejs/test/client.test.ts | 67 ++++++++++++++++ python/README.md | 14 ++++ python/copilot/client.py | 14 +++- python/test_client.py | 102 ++++++++++++++++++++++++- 13 files changed, 397 insertions(+), 11 deletions(-) create mode 100644 dotnet/test/MergeExcludedToolsTests.cs diff --git a/dotnet/README.md b/dotnet/README.md index fe226f77f..93032b798 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -415,6 +415,19 @@ var session = await client.CreateSessionAsync(new SessionConfig When Copilot invokes `lookup_issue`, the client automatically runs your handler and responds to the CLI. Handlers can return any JSON-serializable value (automatically wrapped), or a `ToolResultAIContent` wrapping a `ToolResultObject` for full control over result metadata. +#### Overriding Built-in Tools + +If you register a tool with the same name as a built-in CLI tool (e.g. `edit_file`, `read_file`), your tool takes precedence. The SDK automatically adds the tool name to `ExcludedTools`, so the built-in is disabled and your handler is called instead. This is useful when you need custom behavior for built-in operations. + +```csharp +AIFunctionFactory.Create( + async ([Description("File path")] string path, [Description("New content")] string content) => { + // your logic + }, + "edit_file", + "Custom file editor with project-specific validation") +``` + ### System Message Customization Control the system prompt using `SystemMessage` in session config: diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 9fc0fe8ac..78508c312 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -389,7 +389,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), config.SystemMessage, config.AvailableTools, - config.ExcludedTools, + MergeExcludedTools(config.ExcludedTools, config.Tools), config.Provider, (bool?)true, config.OnUserInputRequest != null ? true : null, @@ -480,7 +480,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), config.SystemMessage, config.AvailableTools, - config.ExcludedTools, + MergeExcludedTools(config.ExcludedTools, config.Tools), config.Provider, (bool?)true, config.OnUserInputRequest != null ? true : null, @@ -862,6 +862,14 @@ private void DispatchLifecycleEvent(SessionLifecycleEvent evt) } } + internal static List? MergeExcludedTools(List? excludedTools, ICollection? tools) + { + var toolNames = tools?.Select(t => t.Name).ToList(); + if (toolNames is null or { Count: 0 }) return excludedTools; + if (excludedTools is null or { Count: 0 }) return toolNames; + return excludedTools.Union(toolNames).ToList(); + } + internal static async Task InvokeRpcAsync(JsonRpc rpc, string method, object?[]? args, CancellationToken cancellationToken) { return await InvokeRpcAsync(rpc, method, args, null, cancellationToken); diff --git a/dotnet/src/GitHub.Copilot.SDK.csproj b/dotnet/src/GitHub.Copilot.SDK.csproj index 019788cfa..7a3fdacaf 100644 --- a/dotnet/src/GitHub.Copilot.SDK.csproj +++ b/dotnet/src/GitHub.Copilot.SDK.csproj @@ -17,6 +17,10 @@ true + + + + diff --git a/dotnet/test/MergeExcludedToolsTests.cs b/dotnet/test/MergeExcludedToolsTests.cs new file mode 100644 index 000000000..a5271a4a0 --- /dev/null +++ b/dotnet/test/MergeExcludedToolsTests.cs @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using Microsoft.Extensions.AI; +using System.ComponentModel; +using Xunit; + +namespace GitHub.Copilot.SDK.Test; + +public class MergeExcludedToolsTests +{ + [Fact] + public void Tool_Names_Are_Added_To_ExcludedTools() + { + var tools = new List + { + AIFunctionFactory.Create(Noop, "my_tool"), + }; + + var result = CopilotClient.MergeExcludedTools(null, tools); + + Assert.NotNull(result); + Assert.Contains("my_tool", result!); + } + + [Fact] + public void Merges_With_Existing_ExcludedTools_And_Deduplicates() + { + var existing = new List { "view", "my_tool" }; + var tools = new List + { + AIFunctionFactory.Create(Noop, "my_tool"), + AIFunctionFactory.Create(Noop, "another_tool"), + }; + + var result = CopilotClient.MergeExcludedTools(existing, tools); + + Assert.NotNull(result); + Assert.Equal(3, result!.Count); + Assert.Contains("view", result); + Assert.Contains("my_tool", result); + Assert.Contains("another_tool", result); + } + + [Fact] + public void Returns_Null_When_No_Tools_Provided() + { + var result = CopilotClient.MergeExcludedTools(null, null); + Assert.Null(result); + } + + [Fact] + public void Returns_ExcludedTools_Unchanged_When_Tools_Empty() + { + var existing = new List { "view" }; + var result = CopilotClient.MergeExcludedTools(existing, new List()); + + Assert.Same(existing, result); + } + + [Fact] + public void Returns_Tool_Names_When_ExcludedTools_Null() + { + var tools = new List + { + AIFunctionFactory.Create(Noop, "my_tool"), + }; + + var result = CopilotClient.MergeExcludedTools(null, tools); + + Assert.NotNull(result); + Assert.Single(result!); + Assert.Equal("my_tool", result[0]); + } + + [Description("No-op")] + static string Noop() => ""; +} diff --git a/go/README.md b/go/README.md index b010fc211..a36c96474 100644 --- a/go/README.md +++ b/go/README.md @@ -267,6 +267,17 @@ session, _ := client.CreateSession(context.Background(), &copilot.SessionConfig{ When the model selects a tool, the SDK automatically runs your handler (in parallel with other calls) and responds to the CLI's `tool.call` with the handler's result. +#### Overriding Built-in Tools + +If you register a tool with the same name as a built-in CLI tool (e.g. `edit_file`, `read_file`), your tool takes precedence. The SDK automatically adds the tool name to `ExcludedTools`, so the built-in is disabled and your handler is called instead. This is useful when you need custom behavior for built-in operations. + +```go +editFile := copilot.DefineTool("edit_file", "Custom file editor with project-specific validation", + func(params EditFileParams, inv copilot.ToolInvocation) (any, error) { + // your logic + }) +``` + ## Streaming Enable streaming to receive assistant response chunks as they're generated: diff --git a/go/client.go b/go/client.go index c88a68ac3..9fcf49d38 100644 --- a/go/client.go +++ b/go/client.go @@ -491,7 +491,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.Tools = config.Tools req.SystemMessage = config.SystemMessage req.AvailableTools = config.AvailableTools - req.ExcludedTools = config.ExcludedTools + req.ExcludedTools = mergeExcludedTools(config.ExcludedTools, config.Tools) req.Provider = config.Provider req.WorkingDirectory = config.WorkingDirectory req.MCPServers = config.MCPServers @@ -588,7 +588,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.Tools = config.Tools req.Provider = config.Provider req.AvailableTools = config.AvailableTools - req.ExcludedTools = config.ExcludedTools + req.ExcludedTools = mergeExcludedTools(config.ExcludedTools, config.Tools) if config.Streaming { req.Streaming = Bool(true) } @@ -1411,6 +1411,29 @@ func buildFailedToolResult(internalError string) ToolResult { } // buildUnsupportedToolResult creates a failure ToolResult for an unsupported tool. +// mergeExcludedTools returns a deduplicated list combining excludedTools with +// the names of any SDK-registered tools, so the CLI won't handle them. +func mergeExcludedTools(excludedTools []string, tools []Tool) []string { + if len(tools) == 0 { + return excludedTools + } + seen := make(map[string]bool, len(excludedTools)+len(tools)) + merged := make([]string, 0, len(excludedTools)+len(tools)) + for _, name := range excludedTools { + if !seen[name] { + seen[name] = true + merged = append(merged, name) + } + } + for _, t := range tools { + if !seen[t.Name] { + seen[t.Name] = true + merged = append(merged, t.Name) + } + } + return merged +} + func buildUnsupportedToolResult(toolName string) ToolResult { return ToolResult{ TextResultForLLM: fmt.Sprintf("Tool '%s' is not supported by this client instance.", toolName), diff --git a/go/client_test.go b/go/client_test.go index 752bdc758..8a0055c49 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -528,3 +528,39 @@ func TestClient_StartStopRace(t *testing.T) { t.Fatal(err) } } + +func TestMergeExcludedTools(t *testing.T) { + t.Run("adds tool names to excluded tools", func(t *testing.T) { + tools := []Tool{{Name: "edit_file"}, {Name: "read_file"}} + got := mergeExcludedTools(nil, tools) + want := []string{"edit_file", "read_file"} + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v", got, want) + } + }) + + t.Run("deduplicates with existing excluded tools", func(t *testing.T) { + excluded := []string{"edit_file", "run_shell"} + tools := []Tool{{Name: "edit_file"}, {Name: "read_file"}} + got := mergeExcludedTools(excluded, tools) + want := []string{"edit_file", "run_shell", "read_file"} + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v", got, want) + } + }) + + t.Run("returns original list when no tools provided", func(t *testing.T) { + excluded := []string{"edit_file"} + got := mergeExcludedTools(excluded, nil) + if !reflect.DeepEqual(got, excluded) { + t.Errorf("got %v, want %v", got, excluded) + } + }) + + t.Run("returns nil when both inputs are empty", func(t *testing.T) { + got := mergeExcludedTools(nil, nil) + if got != nil { + t.Errorf("got %v, want nil", got) + } + }) +} diff --git a/nodejs/README.md b/nodejs/README.md index 31558b8ab..03ed2f751 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -402,6 +402,18 @@ const session = await client.createSession({ When Copilot invokes `lookup_issue`, the client automatically runs your handler and responds to the CLI. Handlers can return any JSON-serializable value (automatically wrapped), a simple string, or a `ToolResultObject` for full control over result metadata. Raw JSON schemas are also supported if Zod isn't desired. +#### Overriding Built-in Tools + +If you register a tool with the same name as a built-in CLI tool (e.g. `edit_file`, `read_file`), your tool takes precedence. The SDK automatically adds the tool name to `excludedTools`, so the built-in is disabled and your handler is called instead. This is useful when you need custom behavior for built-in operations. + +```ts +defineTool("edit_file", { + description: "Custom file editor with project-specific validation", + parameters: z.object({ path: z.string(), content: z.string() }), + handler: async ({ path, content }) => { /* your logic */ }, +}) +``` + ### System Message Customization Control the system prompt using `systemMessage` in session config: diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 6d841c7cc..5d31a5898 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -50,6 +50,19 @@ import type { TypedSessionLifecycleHandler, } from "./types.js"; +/** + * Merge user-provided excludedTools with tool names from config.tools so that + * SDK-registered tools automatically override built-in CLI tools. + */ +function mergeExcludedTools( + excludedTools: string[] | undefined, + tools: Tool[] | undefined +): string[] | undefined { + const toolNames = tools?.map((t) => t.name); + if (!excludedTools?.length && !toolNames?.length) return excludedTools; + return [...new Set([...(excludedTools ?? []), ...(toolNames ?? [])])]; +} + /** * Check if value is a Zod schema (has toJSONSchema method) */ @@ -536,7 +549,7 @@ export class CopilotClient { })), systemMessage: config.systemMessage, availableTools: config.availableTools, - excludedTools: config.excludedTools, + excludedTools: mergeExcludedTools(config.excludedTools, config.tools), provider: config.provider, requestPermission: true, requestUserInput: !!config.onUserInputRequest, @@ -616,7 +629,7 @@ export class CopilotClient { reasoningEffort: config.reasoningEffort, systemMessage: config.systemMessage, availableTools: config.availableTools, - excludedTools: config.excludedTools, + excludedTools: mergeExcludedTools(config.excludedTools, config.tools), tools: config.tools?.map((tool) => ({ name: tool.name, description: tool.description, diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 32257a0b4..335e03faf 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -293,4 +293,71 @@ describe("CopilotClient", () => { }).toThrow(/githubToken and useLoggedInUser cannot be used with cliUrl/); }); }); + + describe("excludedTools merging with config.tools", () => { + it("adds tool names from config.tools to excludedTools in session.create", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi.spyOn((client as any).connection!, "sendRequest"); + await client.createSession({ + tools: [{ name: "edit_file", description: "edit", handler: async () => "ok" }], + }); + + expect(spy).toHaveBeenCalledWith( + "session.create", + expect.objectContaining({ excludedTools: ["edit_file"] }) + ); + }); + + it("merges and deduplicates with existing excludedTools", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi.spyOn((client as any).connection!, "sendRequest"); + await client.createSession({ + tools: [{ name: "edit_file", description: "edit", handler: async () => "ok" }], + excludedTools: ["edit_file", "run_command"], + }); + + const payload = spy.mock.calls.find((c) => c[0] === "session.create")![1] as any; + expect(payload.excludedTools).toEqual( + expect.arrayContaining(["edit_file", "run_command"]) + ); + expect(payload.excludedTools).toHaveLength(2); + }); + + it("leaves excludedTools unchanged when no tools provided", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi.spyOn((client as any).connection!, "sendRequest"); + await client.createSession({ excludedTools: ["run_command"] }); + + expect(spy).toHaveBeenCalledWith( + "session.create", + expect.objectContaining({ excludedTools: ["run_command"] }) + ); + }); + + it("adds tool names from config.tools to excludedTools in session.resume", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const session = await client.createSession(); + const spy = vi.spyOn((client as any).connection!, "sendRequest"); + await client.resumeSession(session.sessionId, { + tools: [{ name: "edit_file", description: "edit", handler: async () => "ok" }], + }); + + expect(spy).toHaveBeenCalledWith( + "session.resume", + expect.objectContaining({ excludedTools: ["edit_file"] }) + ); + }); + }); }); diff --git a/python/README.md b/python/README.md index 3a1c4c73c..d76aa887e 100644 --- a/python/README.md +++ b/python/README.md @@ -210,6 +210,20 @@ session = await client.create_session({ The SDK automatically handles `tool.call`, executes your handler (sync or async), and responds with the final result when the tool completes. +#### Overriding Built-in Tools + +If you register a tool with the same name as a built-in CLI tool (e.g. `edit_file`, `read_file`), your tool takes precedence. The SDK automatically adds the tool name to `excluded_tools`, so the built-in is disabled and your handler is called instead. This is useful when you need custom behavior for built-in operations. + +```python +class EditFileParams(BaseModel): + path: str = Field(description="File path") + content: str = Field(description="New file content") + +@define_tool(name="edit_file", description="Custom file editor with project-specific validation") +async def edit_file(params: EditFileParams) -> str: + # your logic +``` + ## Image Support The SDK supports image attachments via the `attachments` parameter. You can attach images by providing their file path: diff --git a/python/copilot/client.py b/python/copilot/client.py index 60e0d0264..63eb7bb5b 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -496,8 +496,11 @@ async def create_session(self, config: SessionConfig) -> CopilotSession: available_tools = cfg.get("available_tools") if available_tools is not None: payload["availableTools"] = available_tools - excluded_tools = cfg.get("excluded_tools") - if excluded_tools is not None: + excluded_tools = list(cfg.get("excluded_tools") or []) + if tools: + tool_names = [t.name for t in tools] + excluded_tools = list(dict.fromkeys(excluded_tools + tool_names)) + if excluded_tools: payload["excludedTools"] = excluded_tools # Always enable permission request callback (deny by default if no handler provided) @@ -674,8 +677,11 @@ async def resume_session(self, session_id: str, config: ResumeSessionConfig) -> if available_tools is not None: payload["availableTools"] = available_tools - excluded_tools = cfg.get("excluded_tools") - if excluded_tools is not None: + excluded_tools = list(cfg.get("excluded_tools") or []) + if tools: + tool_names = [t.name for t in tools] + excluded_tools = list(dict.fromkeys(excluded_tools + tool_names)) + if excluded_tools: payload["excludedTools"] = excluded_tools provider = cfg.get("provider") diff --git a/python/test_client.py b/python/test_client.py index 0dfe390c9..4879f6b00 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -6,7 +6,7 @@ import pytest -from copilot import CopilotClient, PermissionHandler +from copilot import CopilotClient, PermissionHandler, define_tool from e2e.testharness import CLI_PATH @@ -176,6 +176,106 @@ def test_use_logged_in_user_with_cli_url_raises(self): ) +class TestExcludedToolsFromRegisteredTools: + @pytest.mark.asyncio + async def test_tools_added_to_excluded_tools(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 + + @define_tool(description="Edit a file") + def edit_file(params) -> str: + return "ok" + + await client.create_session({"tools": [edit_file]}) + assert "edit_file" in captured["session.create"]["excludedTools"] + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_deduplication_with_existing_excluded_tools(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 + + @define_tool(description="Edit a file") + def edit_file(params) -> str: + return "ok" + + await client.create_session({ + "tools": [edit_file], + "excluded_tools": ["edit_file", "other_tool"], + }) + excluded = captured["session.create"]["excludedTools"] + assert excluded.count("edit_file") == 1 + assert "other_tool" in excluded + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_no_excluded_tools_when_no_tools(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() + assert "excludedTools" not in captured["session.create"] + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_resume_session_adds_tools_to_excluded(self): + client = CopilotClient({"cli_path": CLI_PATH}) + await client.start() + + try: + session = await client.create_session() + + 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 + + @define_tool(description="Edit a file") + def edit_file(params) -> str: + return "ok" + + await client.resume_session(session.session_id, {"tools": [edit_file]}) + assert "edit_file" in captured["session.resume"]["excludedTools"] + finally: + await client.force_stop() + + class TestSessionConfigForwarding: @pytest.mark.asyncio async def test_create_session_forwards_client_name(self): From 282a2973c3b28c0df94b7fe9a009abbb4ad9eaab Mon Sep 17 00:00:00 2001 From: Patrick Nikoletich Date: Tue, 24 Feb 2026 19:37:59 -0800 Subject: [PATCH 03/10] test: add E2E tests and scenario for tool overrides Add E2E tests across all 4 SDKs verifying that registering a custom tool with the same name as a built-in tool (e.g., 'grep') causes the custom tool to be invoked instead of the built-in. This validates the mergeExcludedTools feature end-to-end. - Add 'overrides built-in tool with custom tool' test to Node, Python, Go, .NET - Add YAML snapshot for the replay proxy - Add test/scenarios/tools/tool-overrides/ with all 4 language implementations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/test/ToolsTests.cs | 23 +++ go/internal/e2e/tools_test.go | 35 +++++ nodejs/test/e2e/tools.test.ts | 20 +++ python/e2e/test_tools.py | 16 ++ test/scenarios/tools/tool-overrides/README.md | 31 ++++ .../tools/tool-overrides/csharp/Program.cs | 39 +++++ .../tools/tool-overrides/csharp/csharp.csproj | 13 ++ test/scenarios/tools/tool-overrides/go/go.mod | 9 ++ test/scenarios/tools/tool-overrides/go/go.sum | 4 + .../scenarios/tools/tool-overrides/go/main.go | 52 +++++++ .../tools/tool-overrides/python/main.py | 45 ++++++ .../tool-overrides/python/requirements.txt | 1 + .../tool-overrides/typescript/package.json | 18 +++ .../tool-overrides/typescript/src/index.ts | 42 ++++++ test/scenarios/tools/tool-overrides/verify.sh | 138 ++++++++++++++++++ ...rrides_built_in_tool_with_custom_tool.yaml | 20 +++ 16 files changed, 506 insertions(+) create mode 100644 test/scenarios/tools/tool-overrides/README.md create mode 100644 test/scenarios/tools/tool-overrides/csharp/Program.cs create mode 100644 test/scenarios/tools/tool-overrides/csharp/csharp.csproj create mode 100644 test/scenarios/tools/tool-overrides/go/go.mod create mode 100644 test/scenarios/tools/tool-overrides/go/go.sum create mode 100644 test/scenarios/tools/tool-overrides/go/main.go create mode 100644 test/scenarios/tools/tool-overrides/python/main.py create mode 100644 test/scenarios/tools/tool-overrides/python/requirements.txt create mode 100644 test/scenarios/tools/tool-overrides/typescript/package.json create mode 100644 test/scenarios/tools/tool-overrides/typescript/src/index.ts create mode 100755 test/scenarios/tools/tool-overrides/verify.sh create mode 100644 test/snapshots/tools/overrides_built_in_tool_with_custom_tool.yaml diff --git a/dotnet/test/ToolsTests.cs b/dotnet/test/ToolsTests.cs index c6449ec8f..886d9463c 100644 --- a/dotnet/test/ToolsTests.cs +++ b/dotnet/test/ToolsTests.cs @@ -152,6 +152,29 @@ record City(int CountryId, string CityName, int Population); [JsonSerializable(typeof(JsonElement))] private partial class ToolsTestsJsonContext : JsonSerializerContext; + [Fact] + public async Task Overrides_Built_In_Tool_With_Custom_Tool() + { + var session = await CreateSessionAsync(new SessionConfig + { + Tools = [AIFunctionFactory.Create(CustomGrep, "grep")], + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + await session.SendAsync(new MessageOptions + { + Prompt = "Use grep to search for the word 'hello'" + }); + + var assistantMessage = await TestHelper.GetFinalAssistantMessageAsync(session); + Assert.NotNull(assistantMessage); + Assert.Contains("CUSTOM_GREP_RESULT", assistantMessage!.Data.Content ?? string.Empty); + + [Description("A custom grep implementation that overrides the built-in")] + static string CustomGrep([Description("Search query")] string query) + => $"CUSTOM_GREP_RESULT: {query}"; + } + [Fact(Skip = "Behaves as if no content was in the result. Likely that binary results aren't fully implemented yet.")] public async Task Can_Return_Binary_Result() { diff --git a/go/internal/e2e/tools_test.go b/go/internal/e2e/tools_test.go index e5b93fa25..563c26dd9 100644 --- a/go/internal/e2e/tools_test.go +++ b/go/internal/e2e/tools_test.go @@ -264,6 +264,41 @@ func TestTools(t *testing.T) { } }) + t.Run("overrides built-in tool with custom tool", func(t *testing.T) { + ctx.ConfigureForTest(t) + + type GrepParams struct { + Query string `json:"query" jsonschema:"Search query"` + } + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Tools: []copilot.Tool{ + copilot.DefineTool("grep", "A custom grep implementation that overrides the built-in", + func(params GrepParams, inv copilot.ToolInvocation) (string, error) { + return "CUSTOM_GREP_RESULT: " + params.Query, nil + }), + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + _, err = session.Send(t.Context(), copilot.MessageOptions{Prompt: "Use grep to search for the word 'hello'"}) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + answer, err := testharness.GetFinalAssistantMessage(t.Context(), session) + if err != nil { + t.Fatalf("Failed to get assistant message: %v", err) + } + + if answer.Data.Content == nil || !strings.Contains(*answer.Data.Content, "CUSTOM_GREP_RESULT") { + t.Errorf("Expected answer to contain 'CUSTOM_GREP_RESULT', got %v", answer.Data.Content) + } + }) + t.Run("invokes custom tool with permission handler", func(t *testing.T) { ctx.ConfigureForTest(t) diff --git a/nodejs/test/e2e/tools.test.ts b/nodejs/test/e2e/tools.test.ts index feab2fbfa..d36172e25 100644 --- a/nodejs/test/e2e/tools.test.ts +++ b/nodejs/test/e2e/tools.test.ts @@ -162,6 +162,26 @@ describe("Custom tools", async () => { expect(customToolRequests[0].toolName).toBe("encrypt_string"); }); + it("overrides built-in tool with custom tool", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + tools: [ + defineTool("grep", { + description: "A custom grep implementation that overrides the built-in", + parameters: z.object({ + query: z.string().describe("Search query"), + }), + handler: ({ query }) => `CUSTOM_GREP_RESULT: ${query}`, + }), + ], + }); + + const assistantMessage = await session.sendAndWait({ + prompt: "Use grep to search for the word 'hello'", + }); + expect(assistantMessage?.data.content).toContain("CUSTOM_GREP_RESULT"); + }); + it("denies custom tool when permission denied", async () => { let toolHandlerCalled = false; diff --git a/python/e2e/test_tools.py b/python/e2e/test_tools.py index e4a9f5f06..e25b23744 100644 --- a/python/e2e/test_tools.py +++ b/python/e2e/test_tools.py @@ -133,6 +133,22 @@ def db_query(params: DbQueryParams, invocation: ToolInvocation) -> list[City]: assert "135460" in response_content.replace(",", "") assert "204356" in response_content.replace(",", "") + async def test_overrides_built_in_tool_with_custom_tool(self, ctx: E2ETestContext): + class GrepParams(BaseModel): + query: str = Field(description="Search query") + + @define_tool("grep", description="A custom grep implementation that overrides the built-in") + def custom_grep(params: GrepParams, invocation: ToolInvocation) -> str: + return f"CUSTOM_GREP_RESULT: {params.query}" + + session = await ctx.client.create_session( + {"tools": [custom_grep], "on_permission_request": PermissionHandler.approve_all} + ) + + await session.send({"prompt": "Use grep to search for the word 'hello'"}) + assistant_message = await get_final_assistant_message(session) + assert "CUSTOM_GREP_RESULT" in assistant_message.data.content + async def test_invokes_custom_tool_with_permission_handler(self, ctx: E2ETestContext): class EncryptParams(BaseModel): input: str = Field(description="String to encrypt") diff --git a/test/scenarios/tools/tool-overrides/README.md b/test/scenarios/tools/tool-overrides/README.md new file mode 100644 index 000000000..f33f22bc3 --- /dev/null +++ b/test/scenarios/tools/tool-overrides/README.md @@ -0,0 +1,31 @@ +# Config Sample: Tool Overrides + +Demonstrates how registering a custom tool with the same name as a built-in tool automatically overrides the built-in. The SDK's `mergeExcludedTools` logic adds custom tool names to `excludedTools`, so the CLI uses your implementation instead. + +## What Each Sample Does + +1. Creates a session with a custom `grep` tool that returns `"CUSTOM_GREP_RESULT: "` +2. Sends: _"Use grep to search for the word 'hello'"_ +3. Prints the response — which should contain `CUSTOM_GREP_RESULT` (proving the custom tool ran, not the built-in) + +## Configuration + +| Option | Value | Effect | +|--------|-------|--------| +| `tools` | Custom `grep` tool | Overrides the built-in `grep` with a custom implementation | + +Behind the scenes, the SDK automatically adds `"grep"` to `excludedTools` so the CLI's built-in grep is disabled. + +## Run + +```bash +./verify.sh +``` + +Requires the `copilot` binary (auto-detected or set `COPILOT_CLI_PATH`) and `GITHUB_TOKEN`. + +## Verification + +The verify script checks that: +- The response contains `CUSTOM_GREP_RESULT` (custom tool was invoked) +- The response does **not** contain typical built-in grep output patterns diff --git a/test/scenarios/tools/tool-overrides/csharp/Program.cs b/test/scenarios/tools/tool-overrides/csharp/Program.cs new file mode 100644 index 000000000..438f53ce6 --- /dev/null +++ b/test/scenarios/tools/tool-overrides/csharp/Program.cs @@ -0,0 +1,39 @@ +using System.ComponentModel; +using GitHub.Copilot.SDK; +using Microsoft.Extensions.AI; + +using var client = new CopilotClient(new CopilotClientOptions +{ + CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), +}); + +await client.StartAsync(); + +try +{ + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Model = "claude-haiku-4.5", + OnPermissionRequest = PermissionHandler.ApproveAll, + Tools = [AIFunctionFactory.Create(CustomGrep, "grep")], + }); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "Use grep to search for the word 'hello'", + }); + + if (response != null) + { + Console.WriteLine(response.Data?.Content); + } +} +finally +{ + await client.StopAsync(); +} + +[Description("A custom grep implementation that overrides the built-in")] +static string CustomGrep([Description("Search query")] string query) + => $"CUSTOM_GREP_RESULT: {query}"; diff --git a/test/scenarios/tools/tool-overrides/csharp/csharp.csproj b/test/scenarios/tools/tool-overrides/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/tools/tool-overrides/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/tools/tool-overrides/go/go.mod b/test/scenarios/tools/tool-overrides/go/go.mod new file mode 100644 index 000000000..353066761 --- /dev/null +++ b/test/scenarios/tools/tool-overrides/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/tools/tool-overrides/go + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../../../../../go diff --git a/test/scenarios/tools/tool-overrides/go/go.sum b/test/scenarios/tools/tool-overrides/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/tools/tool-overrides/go/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/test/scenarios/tools/tool-overrides/go/main.go b/test/scenarios/tools/tool-overrides/go/main.go new file mode 100644 index 000000000..c5cd06f8e --- /dev/null +++ b/test/scenarios/tools/tool-overrides/go/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + copilot "github.com/github/copilot-sdk/go" +) + +type GrepParams struct { + Query string `json:"query" jsonschema:"Search query"` +} + +func main() { + client := copilot.NewClient(&copilot.ClientOptions{ + GitHubToken: os.Getenv("GITHUB_TOKEN"), + }) + + ctx := context.Background() + if err := client.Start(ctx); err != nil { + log.Fatal(err) + } + defer client.Stop() + + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: "claude-haiku-4.5", + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Tools: []copilot.Tool{ + copilot.DefineTool("grep", "A custom grep implementation that overrides the built-in", + func(params GrepParams, inv copilot.ToolInvocation) (string, error) { + return "CUSTOM_GREP_RESULT: " + params.Query, nil + }), + }, + }) + if err != nil { + log.Fatal(err) + } + defer session.Destroy() + + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "Use grep to search for the word 'hello'", + }) + if err != nil { + log.Fatal(err) + } + + if response != nil && response.Data.Content != nil { + fmt.Println(*response.Data.Content) + } +} diff --git a/test/scenarios/tools/tool-overrides/python/main.py b/test/scenarios/tools/tool-overrides/python/main.py new file mode 100644 index 000000000..6e9e870f1 --- /dev/null +++ b/test/scenarios/tools/tool-overrides/python/main.py @@ -0,0 +1,45 @@ +import asyncio +import os + +from pydantic import BaseModel, Field + +from copilot import CopilotClient, PermissionHandler, define_tool + + +class GrepParams(BaseModel): + query: str = Field(description="Search query") + + +@define_tool("grep", description="A custom grep implementation that overrides the built-in") +def custom_grep(params: GrepParams) -> str: + return f"CUSTOM_GREP_RESULT: {params.query}" + + +async def main(): + opts = {"github_token": os.environ.get("GITHUB_TOKEN")} + if os.environ.get("COPILOT_CLI_PATH"): + opts["cli_path"] = os.environ["COPILOT_CLI_PATH"] + client = CopilotClient(opts) + + try: + session = await client.create_session( + { + "model": "claude-haiku-4.5", + "tools": [custom_grep], + "on_permission_request": PermissionHandler.approve_all, + } + ) + + response = await session.send_and_wait( + {"prompt": "Use grep to search for the word 'hello'"} + ) + + if response: + print(response.data.content) + + await session.destroy() + finally: + await client.stop() + + +asyncio.run(main()) diff --git a/test/scenarios/tools/tool-overrides/python/requirements.txt b/test/scenarios/tools/tool-overrides/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/tools/tool-overrides/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/tools/tool-overrides/typescript/package.json b/test/scenarios/tools/tool-overrides/typescript/package.json new file mode 100644 index 000000000..64e958406 --- /dev/null +++ b/test/scenarios/tools/tool-overrides/typescript/package.json @@ -0,0 +1,18 @@ +{ + "name": "tools-tool-overrides-typescript", + "version": "1.0.0", + "private": true, + "description": "Config sample — custom tool overriding a built-in tool", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0" + } +} diff --git a/test/scenarios/tools/tool-overrides/typescript/src/index.ts b/test/scenarios/tools/tool-overrides/typescript/src/index.ts new file mode 100644 index 000000000..a27d89eeb --- /dev/null +++ b/test/scenarios/tools/tool-overrides/typescript/src/index.ts @@ -0,0 +1,42 @@ +import { CopilotClient, defineTool, approveAll } from "@github/copilot-sdk"; +import { z } from "zod"; + +async function main() { + const client = new CopilotClient({ + ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + githubToken: process.env.GITHUB_TOKEN, + }); + + try { + const session = await client.createSession({ + model: "claude-haiku-4.5", + onPermissionRequest: approveAll, + tools: [ + defineTool("grep", { + description: "A custom grep implementation that overrides the built-in", + parameters: z.object({ + query: z.string().describe("Search query"), + }), + handler: ({ query }) => `CUSTOM_GREP_RESULT: ${query}`, + }), + ], + }); + + const response = await session.sendAndWait({ + prompt: "Use grep to search for the word 'hello'", + }); + + if (response) { + console.log(response.data.content); + } + + await session.destroy(); + } finally { + await client.stop(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/scenarios/tools/tool-overrides/verify.sh b/test/scenarios/tools/tool-overrides/verify.sh new file mode 100755 index 000000000..b7687de50 --- /dev/null +++ b/test/scenarios/tools/tool-overrides/verify.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=60 + +# COPILOT_CLI_PATH is optional — the SDK discovers the bundled CLI automatically. +# Set it only to override with a custom binary path. +if [ -n "${COPILOT_CLI_PATH:-}" ]; then + echo "Using CLI override: $COPILOT_CLI_PATH" +fi + +# Ensure GITHUB_TOKEN is set for auth +if [ -z "${GITHUB_TOKEN:-}" ]; then + if command -v gh &>/dev/null; then + export GITHUB_TOKEN=$(gh auth token 2>/dev/null) + fi +fi +if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "⚠️ GITHUB_TOKEN not set and gh auth not available. E2E runs will fail." +fi +echo "" + +# Use gtimeout on macOS, timeout on Linux +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + echo "⚠️ No timeout command found. Install coreutils (brew install coreutils)." + echo " Running without timeouts." + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + + echo "$output" + + # Check that custom grep tool was used (not built-in) + if [ "$code" -eq 0 ] && [ -n "$output" ]; then + if echo "$output" | grep -q "CUSTOM_GREP_RESULT"; then + echo "✅ $name passed (confirmed custom tool override)" + PASS=$((PASS + 1)) + else + echo "⚠️ $name ran but response doesn't contain CUSTOM_GREP_RESULT" + echo "❌ $name failed (expected pattern not found)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + elif [ "$code" -eq 124 ]; then + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +echo "══════════════════════════════════════" +echo " Verifying tools/tool-overrides samples" +echo " Phase 1: Build" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: install + compile +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# Python: install + syntax +check "Python (install)" bash -c "python3 -c 'import copilot' 2>/dev/null || (cd '$SCRIPT_DIR/python' && pip3 install -r requirements.txt --quiet 2>&1)" +check "Python (syntax)" bash -c "python3 -c \"import ast; ast.parse(open('$SCRIPT_DIR/python/main.py').read()); print('Syntax OK')\"" + +# Go: build +check "Go (build)" bash -c "cd '$SCRIPT_DIR/go' && go build -o tool-overrides-go . 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + + +echo "══════════════════════════════════════" +echo " Phase 2: E2E Run (timeout ${TIMEOUT}s each)" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: run +run_with_timeout "TypeScript (run)" bash -c "cd '$SCRIPT_DIR/typescript' && node dist/index.js" + +# Python: run +run_with_timeout "Python (run)" bash -c "cd '$SCRIPT_DIR/python' && python3 main.py" + +# Go: run +run_with_timeout "Go (run)" bash -c "cd '$SCRIPT_DIR/go' && ./tool-overrides-go" + +# C#: run +run_with_timeout "C# (run)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet run --no-build 2>&1" + + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/snapshots/tools/overrides_built_in_tool_with_custom_tool.yaml b/test/snapshots/tools/overrides_built_in_tool_with_custom_tool.yaml new file mode 100644 index 000000000..8aea64eaa --- /dev/null +++ b/test/snapshots/tools/overrides_built_in_tool_with_custom_tool.yaml @@ -0,0 +1,20 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: "Use grep to search for the word 'hello'" + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: grep + arguments: '{"query":"hello"}' + - role: tool + tool_call_id: toolcall_0 + content: "CUSTOM_GREP_RESULT: hello" + - role: assistant + content: "The grep result is: **CUSTOM_GREP_RESULT: hello**" From 2ff53d3ce9673a69f8ac5fa97a54095e948fbd0e Mon Sep 17 00:00:00 2001 From: Patrick Nikoletich Date: Thu, 26 Feb 2026 16:30:26 -0800 Subject: [PATCH 04/10] fix: address review findings from PR #523 - Fix Python tests: add missing required argument to create_session() calls - Fix Go: separate misplaced buildUnsupportedToolResult doc comment from mergeExcludedTools - Fix Go sample: whitespace alignment from merge Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- go/client.go | 2 +- python/test_client.py | 4 ++-- test/scenarios/tools/tool-overrides/go/main.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go/client.go b/go/client.go index 9fcf49d38..d147edcbb 100644 --- a/go/client.go +++ b/go/client.go @@ -1410,7 +1410,6 @@ func buildFailedToolResult(internalError string) ToolResult { } } -// buildUnsupportedToolResult creates a failure ToolResult for an unsupported tool. // mergeExcludedTools returns a deduplicated list combining excludedTools with // the names of any SDK-registered tools, so the CLI won't handle them. func mergeExcludedTools(excludedTools []string, tools []Tool) []string { @@ -1434,6 +1433,7 @@ func mergeExcludedTools(excludedTools []string, tools []Tool) []string { return merged } +// buildUnsupportedToolResult creates a failure ToolResult for an unsupported tool. func buildUnsupportedToolResult(toolName string) ToolResult { return ToolResult{ TextResultForLLM: fmt.Sprintf("Tool '%s' is not supported by this client instance.", toolName), diff --git a/python/test_client.py b/python/test_client.py index 4879f6b00..655d8e88d 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -244,7 +244,7 @@ async def mock_request(method, params): return await original_request(method, params) client._client.request = mock_request - await client.create_session() + await client.create_session({}) assert "excludedTools" not in captured["session.create"] finally: await client.force_stop() @@ -255,7 +255,7 @@ async def test_resume_session_adds_tools_to_excluded(self): await client.start() try: - session = await client.create_session() + session = await client.create_session({}) captured = {} original_request = client._client.request diff --git a/test/scenarios/tools/tool-overrides/go/main.go b/test/scenarios/tools/tool-overrides/go/main.go index c5cd06f8e..f2f5119d3 100644 --- a/test/scenarios/tools/tool-overrides/go/main.go +++ b/test/scenarios/tools/tool-overrides/go/main.go @@ -25,7 +25,7 @@ func main() { defer client.Stop() session, err := client.CreateSession(ctx, &copilot.SessionConfig{ - Model: "claude-haiku-4.5", + Model: "claude-haiku-4.5", OnPermissionRequest: copilot.PermissionHandler.ApproveAll, Tools: []copilot.Tool{ copilot.DefineTool("grep", "A custom grep implementation that overrides the built-in", From 21f595e6eb35feb9c0bb1b7dd195bc13fca73462 Mon Sep 17 00:00:00 2001 From: Patrick Nikoletich Date: Thu, 26 Feb 2026 16:37:54 -0800 Subject: [PATCH 05/10] style: fix ruff formatting in Python test_client.py Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/test_client.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/python/test_client.py b/python/test_client.py index 655d8e88d..7546b3544 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -220,10 +220,12 @@ async def mock_request(method, params): def edit_file(params) -> str: return "ok" - await client.create_session({ - "tools": [edit_file], - "excluded_tools": ["edit_file", "other_tool"], - }) + await client.create_session( + { + "tools": [edit_file], + "excluded_tools": ["edit_file", "other_tool"], + } + ) excluded = captured["session.create"]["excludedTools"] assert excluded.count("edit_file") == 1 assert "other_tool" in excluded From b49777ee05eb0bed0466e5c31beb23d0fc6aca4e Mon Sep 17 00:00:00 2001 From: Patrick Nikoletich Date: Thu, 26 Feb 2026 18:29:59 -0800 Subject: [PATCH 06/10] chore: update snapshot from E2E run Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tools/overrides_built_in_tool_with_custom_tool.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/snapshots/tools/overrides_built_in_tool_with_custom_tool.yaml b/test/snapshots/tools/overrides_built_in_tool_with_custom_tool.yaml index 8aea64eaa..6865beeb5 100644 --- a/test/snapshots/tools/overrides_built_in_tool_with_custom_tool.yaml +++ b/test/snapshots/tools/overrides_built_in_tool_with_custom_tool.yaml @@ -5,7 +5,7 @@ conversations: - role: system content: ${system} - role: user - content: "Use grep to search for the word 'hello'" + content: Use grep to search for the word 'hello' - role: assistant tool_calls: - id: toolcall_0 From 17b567bd9cabdb1e017367d250e84d9d568cd9e3 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 27 Feb 2026 12:23:17 +0000 Subject: [PATCH 07/10] Replace excludedTools merging with overridesBuiltInTool flag Instead of merging SDK tool names into excludedTools (which semantically abuses the exclude mechanism), each tool now declares overridesBuiltInTool when it intentionally replaces a built-in tool. The runtime uses this flag to detect accidental name clashes (error) vs intentional overrides. Changes across all 4 SDKs (Node.js, Python, Go, .NET): - Add overridesBuiltInTool field to tool definitions - Remove mergeExcludedTools / inline merge logic - Pass overridesBuiltInTool through wire protocol to runtime - Update tests and documentation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/README.md | 10 ++- dotnet/src/Client.cs | 26 +++---- dotnet/src/Types.cs | 17 +++++ dotnet/test/MergeExcludedToolsTests.cs | 68 +++++++------------ dotnet/test/ToolsTests.cs | 1 + go/README.md | 3 +- go/client.go | 27 +------- go/client_test.go | 41 +++++++++++ go/internal/e2e/tools_test.go | 11 +-- go/types.go | 9 +-- nodejs/README.md | 3 +- nodejs/src/client.ts | 19 ++---- nodejs/src/types.ts | 7 ++ nodejs/test/client.test.ts | 55 +++++++-------- nodejs/test/e2e/tools.test.ts | 1 + python/README.md | 4 +- python/copilot/client.py | 14 ++-- python/copilot/tools.py | 2 + python/copilot/types.py | 1 + python/e2e/test_tools.py | 2 +- python/test_client.py | 60 ++++++++-------- test/scenarios/tools/tool-overrides/README.md | 9 +-- .../tools/tool-overrides/csharp/Program.cs | 1 + .../scenarios/tools/tool-overrides/go/main.go | 13 ++-- .../tools/tool-overrides/python/main.py | 2 +- .../tool-overrides/typescript/src/index.ts | 1 + 26 files changed, 222 insertions(+), 185 deletions(-) diff --git a/dotnet/README.md b/dotnet/README.md index 93032b798..7d6ef60c6 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -417,15 +417,23 @@ When Copilot invokes `lookup_issue`, the client automatically runs your handler #### Overriding Built-in Tools -If you register a tool with the same name as a built-in CLI tool (e.g. `edit_file`, `read_file`), your tool takes precedence. The SDK automatically adds the tool name to `ExcludedTools`, so the built-in is disabled and your handler is called instead. This is useful when you need custom behavior for built-in operations. +If you register a tool with the same name as a built-in CLI tool (e.g. `edit_file`, `read_file`), the SDK will throw an error unless you explicitly opt in by setting `BuiltInToolOverrides` on the session config. This flag signals that you intend to replace the built-in tool with your custom implementation. ```csharp +// Register the custom tool AIFunctionFactory.Create( async ([Description("File path")] string path, [Description("New content")] string content) => { // your logic }, "edit_file", "Custom file editor with project-specific validation") + +// Opt in to overriding the built-in tool in session config +var session = await client.CreateSessionAsync(new SessionConfig +{ + Model = "gpt-5", + BuiltInToolOverrides = new HashSet { "edit_file" } +}); ``` ### System Message Customization diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 78508c312..0af65ad10 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -386,10 +386,11 @@ public async Task CreateSessionAsync(SessionConfig config, Cance config.SessionId, config.ClientName, config.ReasoningEffort, - config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), + config.Tools?.Select(f => ToolDefinition.FromAIFunction(f, + config.BuiltInToolOverrides?.Contains(f.Name) == true)).ToList(), config.SystemMessage, config.AvailableTools, - MergeExcludedTools(config.ExcludedTools, config.Tools), + config.ExcludedTools, config.Provider, (bool?)true, config.OnUserInputRequest != null ? true : null, @@ -477,10 +478,11 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config.ClientName, config.Model, config.ReasoningEffort, - config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), + config.Tools?.Select(f => ToolDefinition.FromAIFunction(f, + config.BuiltInToolOverrides?.Contains(f.Name) == true)).ToList(), config.SystemMessage, config.AvailableTools, - MergeExcludedTools(config.ExcludedTools, config.Tools), + config.ExcludedTools, config.Provider, (bool?)true, config.OnUserInputRequest != null ? true : null, @@ -862,14 +864,6 @@ private void DispatchLifecycleEvent(SessionLifecycleEvent evt) } } - internal static List? MergeExcludedTools(List? excludedTools, ICollection? tools) - { - var toolNames = tools?.Select(t => t.Name).ToList(); - if (toolNames is null or { Count: 0 }) return excludedTools; - if (excludedTools is null or { Count: 0 }) return toolNames; - return excludedTools.Union(toolNames).ToList(); - } - internal static async Task InvokeRpcAsync(JsonRpc rpc, string method, object?[]? args, CancellationToken cancellationToken) { return await InvokeRpcAsync(rpc, method, args, null, cancellationToken); @@ -1424,10 +1418,12 @@ internal record CreateSessionRequest( internal record ToolDefinition( string Name, string? Description, - JsonElement Parameters /* JSON schema */) + JsonElement Parameters, /* JSON schema */ + bool? OverridesBuiltInTool = null) { - public static ToolDefinition FromAIFunction(AIFunction function) - => new ToolDefinition(function.Name, function.Description, function.JsonSchema); + public static ToolDefinition FromAIFunction(AIFunction function, bool overridesBuiltInTool = false) + => new ToolDefinition(function.Name, function.Description, function.JsonSchema, + overridesBuiltInTool ? true : null); } internal record CreateSessionResponse( diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 1b716cd41..2948bb34d 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -775,6 +775,7 @@ protected SessionConfig(SessionConfig? other) Streaming = other.Streaming; SystemMessage = other.SystemMessage; Tools = other.Tools is not null ? [.. other.Tools] : null; + BuiltInToolOverrides = other.BuiltInToolOverrides is not null ? [.. other.BuiltInToolOverrides] : null; WorkingDirectory = other.WorkingDirectory; } @@ -802,6 +803,14 @@ protected SessionConfig(SessionConfig? other) public string? ConfigDir { get; set; } public ICollection? Tools { get; set; } + + /// + /// Set of tool names that are intended to override built-in tools of the same name. + /// If a tool name clashes with a built-in tool and is not in this set, the runtime + /// will return an error. + /// + public HashSet? BuiltInToolOverrides { get; set; } + public SystemMessageConfig? SystemMessage { get; set; } public List? AvailableTools { get; set; } public List? ExcludedTools { get; set; } @@ -912,6 +921,7 @@ protected ResumeSessionConfig(ResumeSessionConfig? other) Streaming = other.Streaming; SystemMessage = other.SystemMessage; Tools = other.Tools is not null ? [.. other.Tools] : null; + BuiltInToolOverrides = other.BuiltInToolOverrides is not null ? [.. other.BuiltInToolOverrides] : null; WorkingDirectory = other.WorkingDirectory; } @@ -928,6 +938,13 @@ protected ResumeSessionConfig(ResumeSessionConfig? other) public ICollection? Tools { get; set; } + /// + /// Set of tool names that are intended to override built-in tools of the same name. + /// If a tool name clashes with a built-in tool and is not in this set, the runtime + /// will return an error. + /// + public HashSet? BuiltInToolOverrides { get; set; } + /// /// System message configuration. /// diff --git a/dotnet/test/MergeExcludedToolsTests.cs b/dotnet/test/MergeExcludedToolsTests.cs index a5271a4a0..9455abf1c 100644 --- a/dotnet/test/MergeExcludedToolsTests.cs +++ b/dotnet/test/MergeExcludedToolsTests.cs @@ -4,74 +4,56 @@ using Microsoft.Extensions.AI; using System.ComponentModel; +using System.Text.Json; using Xunit; namespace GitHub.Copilot.SDK.Test; -public class MergeExcludedToolsTests +public class OverridesBuiltInToolTests { [Fact] - public void Tool_Names_Are_Added_To_ExcludedTools() + public void ToolDefinition_FromAIFunction_Sets_OverridesBuiltInTool() { - var tools = new List - { - AIFunctionFactory.Create(Noop, "my_tool"), - }; + var fn = AIFunctionFactory.Create(Noop, "grep"); + var def = CopilotClient.ToolDefinition.FromAIFunction(fn, overridesBuiltInTool: true); - var result = CopilotClient.MergeExcludedTools(null, tools); - - Assert.NotNull(result); - Assert.Contains("my_tool", result!); + Assert.Equal("grep", def.Name); + Assert.True(def.OverridesBuiltInTool); } [Fact] - public void Merges_With_Existing_ExcludedTools_And_Deduplicates() + public void ToolDefinition_FromAIFunction_Omits_OverridesBuiltInTool_When_False() { - var existing = new List { "view", "my_tool" }; - var tools = new List - { - AIFunctionFactory.Create(Noop, "my_tool"), - AIFunctionFactory.Create(Noop, "another_tool"), - }; - - var result = CopilotClient.MergeExcludedTools(existing, tools); + var fn = AIFunctionFactory.Create(Noop, "custom_tool"); + var def = CopilotClient.ToolDefinition.FromAIFunction(fn, overridesBuiltInTool: false); - Assert.NotNull(result); - Assert.Equal(3, result!.Count); - Assert.Contains("view", result); - Assert.Contains("my_tool", result); - Assert.Contains("another_tool", result); + Assert.Equal("custom_tool", def.Name); + Assert.Null(def.OverridesBuiltInTool); } [Fact] - public void Returns_Null_When_No_Tools_Provided() + public void SessionConfig_BuiltInToolOverrides_Is_Used() { - var result = CopilotClient.MergeExcludedTools(null, null); - Assert.Null(result); - } - - [Fact] - public void Returns_ExcludedTools_Unchanged_When_Tools_Empty() - { - var existing = new List { "view" }; - var result = CopilotClient.MergeExcludedTools(existing, new List()); + var config = new SessionConfig + { + Tools = new List { AIFunctionFactory.Create(Noop, "grep") }, + BuiltInToolOverrides = new HashSet { "grep" }, + }; - Assert.Same(existing, result); + Assert.Contains("grep", config.BuiltInToolOverrides); } [Fact] - public void Returns_Tool_Names_When_ExcludedTools_Null() + public void ResumeSessionConfig_BuiltInToolOverrides_Is_Used() { - var tools = new List + var config = new ResumeSessionConfig { - AIFunctionFactory.Create(Noop, "my_tool"), + Tools = new List { AIFunctionFactory.Create(Noop, "grep") }, + BuiltInToolOverrides = new HashSet { "grep" }, }; - var result = CopilotClient.MergeExcludedTools(null, tools); - - Assert.NotNull(result); - Assert.Single(result!); - Assert.Equal("my_tool", result[0]); + Assert.NotNull(config.BuiltInToolOverrides); + Assert.Contains("grep", config.BuiltInToolOverrides!); } [Description("No-op")] diff --git a/dotnet/test/ToolsTests.cs b/dotnet/test/ToolsTests.cs index 886d9463c..0ca98ee0d 100644 --- a/dotnet/test/ToolsTests.cs +++ b/dotnet/test/ToolsTests.cs @@ -158,6 +158,7 @@ public async Task Overrides_Built_In_Tool_With_Custom_Tool() var session = await CreateSessionAsync(new SessionConfig { Tools = [AIFunctionFactory.Create(CustomGrep, "grep")], + BuiltInToolOverrides = ["grep"], OnPermissionRequest = PermissionHandler.ApproveAll, }); diff --git a/go/README.md b/go/README.md index a36c96474..b95061512 100644 --- a/go/README.md +++ b/go/README.md @@ -269,13 +269,14 @@ When the model selects a tool, the SDK automatically runs your handler (in paral #### Overriding Built-in Tools -If you register a tool with the same name as a built-in CLI tool (e.g. `edit_file`, `read_file`), your tool takes precedence. The SDK automatically adds the tool name to `ExcludedTools`, so the built-in is disabled and your handler is called instead. This is useful when you need custom behavior for built-in operations. +If you register a tool with the same name as a built-in CLI tool (e.g. `edit_file`, `read_file`), the SDK will throw an error unless you explicitly opt in by setting `OverridesBuiltInTool = true`. This flag signals that you intend to replace the built-in tool with your custom implementation. ```go editFile := copilot.DefineTool("edit_file", "Custom file editor with project-specific validation", func(params EditFileParams, inv copilot.ToolInvocation) (any, error) { // your logic }) +editFile.OverridesBuiltInTool = true ``` ## Streaming diff --git a/go/client.go b/go/client.go index d147edcbb..c88a68ac3 100644 --- a/go/client.go +++ b/go/client.go @@ -491,7 +491,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.Tools = config.Tools req.SystemMessage = config.SystemMessage req.AvailableTools = config.AvailableTools - req.ExcludedTools = mergeExcludedTools(config.ExcludedTools, config.Tools) + req.ExcludedTools = config.ExcludedTools req.Provider = config.Provider req.WorkingDirectory = config.WorkingDirectory req.MCPServers = config.MCPServers @@ -588,7 +588,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.Tools = config.Tools req.Provider = config.Provider req.AvailableTools = config.AvailableTools - req.ExcludedTools = mergeExcludedTools(config.ExcludedTools, config.Tools) + req.ExcludedTools = config.ExcludedTools if config.Streaming { req.Streaming = Bool(true) } @@ -1410,29 +1410,6 @@ func buildFailedToolResult(internalError string) ToolResult { } } -// mergeExcludedTools returns a deduplicated list combining excludedTools with -// the names of any SDK-registered tools, so the CLI won't handle them. -func mergeExcludedTools(excludedTools []string, tools []Tool) []string { - if len(tools) == 0 { - return excludedTools - } - seen := make(map[string]bool, len(excludedTools)+len(tools)) - merged := make([]string, 0, len(excludedTools)+len(tools)) - for _, name := range excludedTools { - if !seen[name] { - seen[name] = true - merged = append(merged, name) - } - } - for _, t := range tools { - if !seen[t.Name] { - seen[t.Name] = true - merged = append(merged, t.Name) - } - } - return merged -} - // buildUnsupportedToolResult creates a failure ToolResult for an unsupported tool. func buildUnsupportedToolResult(toolName string) ToolResult { return ToolResult{ diff --git a/go/client_test.go b/go/client_test.go index 8a0055c49..dc2fd2579 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -448,6 +448,47 @@ func TestResumeSessionRequest_ClientName(t *testing.T) { }) } +func TestOverridesBuiltInTool(t *testing.T) { + t.Run("OverridesBuiltInTool is serialized in tool definition", func(t *testing.T) { + tool := Tool{ + Name: "grep", + Description: "Custom grep", + OverridesBuiltInTool: true, + Handler: func(_ ToolInvocation) (ToolResult, error) { return ToolResult{}, nil }, + } + data, err := json.Marshal(tool) + 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 v, ok := m["overridesBuiltInTool"]; !ok || v != true { + t.Errorf("expected overridesBuiltInTool=true, got %v", m) + } + }) + + t.Run("OverridesBuiltInTool omitted when false", func(t *testing.T) { + tool := Tool{ + Name: "custom_tool", + Description: "A custom tool", + Handler: func(_ ToolInvocation) (ToolResult, error) { return ToolResult{}, nil }, + } + data, err := json.Marshal(tool) + 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 _, ok := m["overridesBuiltInTool"]; ok { + t.Errorf("expected overridesBuiltInTool to be omitted, got %v", m) + } + }) +} + func TestClient_CreateSession_RequiresPermissionHandler(t *testing.T) { t.Run("returns error when config is nil", func(t *testing.T) { client := NewClient(nil) diff --git a/go/internal/e2e/tools_test.go b/go/internal/e2e/tools_test.go index 563c26dd9..342f78de5 100644 --- a/go/internal/e2e/tools_test.go +++ b/go/internal/e2e/tools_test.go @@ -271,13 +271,16 @@ func TestTools(t *testing.T) { Query string `json:"query" jsonschema:"Search query"` } + grepTool := copilot.DefineTool("grep", "A custom grep implementation that overrides the built-in", + func(params GrepParams, inv copilot.ToolInvocation) (string, error) { + return "CUSTOM_GREP_RESULT: " + params.Query, nil + }) + grepTool.OverridesBuiltInTool = true + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ OnPermissionRequest: copilot.PermissionHandler.ApproveAll, Tools: []copilot.Tool{ - copilot.DefineTool("grep", "A custom grep implementation that overrides the built-in", - func(params GrepParams, inv copilot.ToolInvocation) (string, error) { - return "CUSTOM_GREP_RESULT: " + params.Query, nil - }), + grepTool, }, }) if err != nil { diff --git a/go/types.go b/go/types.go index 225cc1266..8f034db7d 100644 --- a/go/types.go +++ b/go/types.go @@ -410,10 +410,11 @@ type SessionConfig struct { // Tool describes a caller-implemented tool that can be invoked by Copilot type Tool struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - Parameters map[string]any `json:"parameters,omitempty"` - Handler ToolHandler `json:"-"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters map[string]any `json:"parameters,omitempty"` + OverridesBuiltInTool bool `json:"overridesBuiltInTool,omitempty"` + Handler ToolHandler `json:"-"` } // ToolInvocation describes a tool call initiated by Copilot diff --git a/nodejs/README.md b/nodejs/README.md index 03ed2f751..1a84f38b2 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -404,12 +404,13 @@ When Copilot invokes `lookup_issue`, the client automatically runs your handler #### Overriding Built-in Tools -If you register a tool with the same name as a built-in CLI tool (e.g. `edit_file`, `read_file`), your tool takes precedence. The SDK automatically adds the tool name to `excludedTools`, so the built-in is disabled and your handler is called instead. This is useful when you need custom behavior for built-in operations. +If you register a tool with the same name as a built-in CLI tool (e.g. `edit_file`, `read_file`), the SDK will throw an error unless you explicitly opt in by setting `overridesBuiltInTool: true`. This flag signals that you intend to replace the built-in tool with your custom implementation. ```ts defineTool("edit_file", { description: "Custom file editor with project-specific validation", parameters: z.object({ path: z.string(), content: z.string() }), + overridesBuiltInTool: true, handler: async ({ path, content }) => { /* your logic */ }, }) ``` diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 5d31a5898..fe8655b55 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -50,19 +50,6 @@ import type { TypedSessionLifecycleHandler, } from "./types.js"; -/** - * Merge user-provided excludedTools with tool names from config.tools so that - * SDK-registered tools automatically override built-in CLI tools. - */ -function mergeExcludedTools( - excludedTools: string[] | undefined, - tools: Tool[] | undefined -): string[] | undefined { - const toolNames = tools?.map((t) => t.name); - if (!excludedTools?.length && !toolNames?.length) return excludedTools; - return [...new Set([...(excludedTools ?? []), ...(toolNames ?? [])])]; -} - /** * Check if value is a Zod schema (has toJSONSchema method) */ @@ -546,10 +533,11 @@ export class CopilotClient { name: tool.name, description: tool.description, parameters: toJsonSchema(tool.parameters), + overridesBuiltInTool: tool.overridesBuiltInTool, })), systemMessage: config.systemMessage, availableTools: config.availableTools, - excludedTools: mergeExcludedTools(config.excludedTools, config.tools), + excludedTools: config.excludedTools, provider: config.provider, requestPermission: true, requestUserInput: !!config.onUserInputRequest, @@ -629,11 +617,12 @@ export class CopilotClient { reasoningEffort: config.reasoningEffort, systemMessage: config.systemMessage, availableTools: config.availableTools, - excludedTools: mergeExcludedTools(config.excludedTools, config.tools), + excludedTools: config.excludedTools, tools: config.tools?.map((tool) => ({ name: tool.name, description: tool.description, parameters: toJsonSchema(tool.parameters), + overridesBuiltInTool: tool.overridesBuiltInTool, })), provider: config.provider, requestPermission: true, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 3a0ccbce7..482216a98 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -146,6 +146,12 @@ export interface Tool { description?: string; parameters?: ZodSchema | Record; handler: ToolHandler; + /** + * When true, explicitly indicates this tool is intended to override a built-in tool + * of the same name. If not set and the name clashes with a built-in tool, the runtime + * will return an error. + */ + overridesBuiltInTool?: boolean; } /** @@ -158,6 +164,7 @@ export function defineTool( description?: string; parameters?: ZodSchema | Record; handler: ToolHandler; + overridesBuiltInTool?: boolean; } ): Tool { return { name, ...config }; diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 335e03faf..41dbc8743 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -294,70 +294,71 @@ describe("CopilotClient", () => { }); }); - describe("excludedTools merging with config.tools", () => { - it("adds tool names from config.tools to excludedTools in session.create", async () => { + describe("overridesBuiltInTool in tool definitions", () => { + it("sends overridesBuiltInTool in tool definition on session.create", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ - tools: [{ name: "edit_file", description: "edit", handler: async () => "ok" }], + onPermissionRequest: approveAll, + tools: [{ name: "grep", description: "custom grep", handler: async () => "ok", overridesBuiltInTool: true }], }); - expect(spy).toHaveBeenCalledWith( - "session.create", - expect.objectContaining({ excludedTools: ["edit_file"] }) - ); + const payload = spy.mock.calls.find((c) => c[0] === "session.create")![1] as any; + expect(payload.tools).toEqual([ + expect.objectContaining({ name: "grep", overridesBuiltInTool: true }) + ]); }); - it("merges and deduplicates with existing excludedTools", async () => { + it("does not merge tool names into excludedTools", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ - tools: [{ name: "edit_file", description: "edit", handler: async () => "ok" }], - excludedTools: ["edit_file", "run_command"], + onPermissionRequest: approveAll, + tools: [{ name: "grep", description: "custom grep", handler: async () => "ok", overridesBuiltInTool: true }], }); const payload = spy.mock.calls.find((c) => c[0] === "session.create")![1] as any; - expect(payload.excludedTools).toEqual( - expect.arrayContaining(["edit_file", "run_command"]) - ); - expect(payload.excludedTools).toHaveLength(2); + expect(payload.excludedTools).toBeUndefined(); }); - it("leaves excludedTools unchanged when no tools provided", async () => { + it("preserves user-specified excludedTools without adding tool names", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const spy = vi.spyOn((client as any).connection!, "sendRequest"); - await client.createSession({ excludedTools: ["run_command"] }); + await client.createSession({ + onPermissionRequest: approveAll, + tools: [{ name: "grep", description: "custom grep", handler: async () => "ok", overridesBuiltInTool: true }], + excludedTools: ["bash"], + }); - expect(spy).toHaveBeenCalledWith( - "session.create", - expect.objectContaining({ excludedTools: ["run_command"] }) - ); + const payload = spy.mock.calls.find((c) => c[0] === "session.create")![1] as any; + expect(payload.excludedTools).toEqual(["bash"]); }); - it("adds tool names from config.tools to excludedTools in session.resume", async () => { + it("sends overridesBuiltInTool in tool definition on session.resume", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); - const session = await client.createSession(); + const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.resumeSession(session.sessionId, { - tools: [{ name: "edit_file", description: "edit", handler: async () => "ok" }], + onPermissionRequest: approveAll, + tools: [{ name: "grep", description: "custom grep", handler: async () => "ok", overridesBuiltInTool: true }], }); - expect(spy).toHaveBeenCalledWith( - "session.resume", - expect.objectContaining({ excludedTools: ["edit_file"] }) - ); + const payload = spy.mock.calls.find((c) => c[0] === "session.resume")![1] as any; + expect(payload.tools).toEqual([ + expect.objectContaining({ name: "grep", overridesBuiltInTool: true }) + ]); }); }); }); diff --git a/nodejs/test/e2e/tools.test.ts b/nodejs/test/e2e/tools.test.ts index d36172e25..724f36b90 100644 --- a/nodejs/test/e2e/tools.test.ts +++ b/nodejs/test/e2e/tools.test.ts @@ -172,6 +172,7 @@ describe("Custom tools", async () => { query: z.string().describe("Search query"), }), handler: ({ query }) => `CUSTOM_GREP_RESULT: ${query}`, + overridesBuiltInTool: true, }), ], }); diff --git a/python/README.md b/python/README.md index d76aa887e..9755f85fd 100644 --- a/python/README.md +++ b/python/README.md @@ -212,14 +212,14 @@ The SDK automatically handles `tool.call`, executes your handler (sync or async) #### Overriding Built-in Tools -If you register a tool with the same name as a built-in CLI tool (e.g. `edit_file`, `read_file`), your tool takes precedence. The SDK automatically adds the tool name to `excluded_tools`, so the built-in is disabled and your handler is called instead. This is useful when you need custom behavior for built-in operations. +If you register a tool with the same name as a built-in CLI tool (e.g. `edit_file`, `read_file`), the SDK will throw an error unless you explicitly opt in by setting `overrides_built_in_tool=True`. This flag signals that you intend to replace the built-in tool with your custom implementation. ```python class EditFileParams(BaseModel): path: str = Field(description="File path") content: str = Field(description="New file content") -@define_tool(name="edit_file", description="Custom file editor with project-specific validation") +@define_tool(name="edit_file", description="Custom file editor with project-specific validation", overrides_built_in_tool=True) async def edit_file(params: EditFileParams) -> str: # your logic ``` diff --git a/python/copilot/client.py b/python/copilot/client.py index 63eb7bb5b..93d661495 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -467,12 +467,14 @@ async def create_session(self, config: SessionConfig) -> CopilotSession: tools = cfg.get("tools") if tools: for tool in tools: - definition = { + definition: dict[str, Any] = { "name": tool.name, "description": tool.description, } if tool.parameters: definition["parameters"] = tool.parameters + if tool.overrides_built_in_tool: + definition["overridesBuiltInTool"] = True tool_defs.append(definition) payload: dict[str, Any] = {} @@ -497,9 +499,6 @@ async def create_session(self, config: SessionConfig) -> CopilotSession: if available_tools is not None: payload["availableTools"] = available_tools excluded_tools = list(cfg.get("excluded_tools") or []) - if tools: - tool_names = [t.name for t in tools] - excluded_tools = list(dict.fromkeys(excluded_tools + tool_names)) if excluded_tools: payload["excludedTools"] = excluded_tools @@ -642,12 +641,14 @@ async def resume_session(self, session_id: str, config: ResumeSessionConfig) -> tools = cfg.get("tools") if tools: for tool in tools: - definition = { + definition: dict[str, Any] = { "name": tool.name, "description": tool.description, } if tool.parameters: definition["parameters"] = tool.parameters + if tool.overrides_built_in_tool: + definition["overridesBuiltInTool"] = True tool_defs.append(definition) payload: dict[str, Any] = {"sessionId": session_id} @@ -678,9 +679,6 @@ async def resume_session(self, session_id: str, config: ResumeSessionConfig) -> payload["availableTools"] = available_tools excluded_tools = list(cfg.get("excluded_tools") or []) - if tools: - tool_names = [t.name for t in tools] - excluded_tools = list(dict.fromkeys(excluded_tools + tool_names)) if excluded_tools: payload["excludedTools"] = excluded_tools diff --git a/python/copilot/tools.py b/python/copilot/tools.py index e3e600992..0b80323d7 100644 --- a/python/copilot/tools.py +++ b/python/copilot/tools.py @@ -44,6 +44,7 @@ def define_tool( description: str | None = None, handler: Callable[[Any, ToolInvocation], Any] | None = None, params_type: type[BaseModel] | None = None, + overrides_built_in_tool: bool = False, ) -> Tool | Callable[[Callable[[Any, ToolInvocation], Any]], Tool]: """ Define a tool with automatic JSON schema generation from Pydantic models. @@ -150,6 +151,7 @@ async def wrapped_handler(invocation: ToolInvocation) -> ToolResult: description=description or "", parameters=schema, handler=wrapped_handler, + overrides_built_in_tool=overrides_built_in_tool, ) # If handler is provided, call decorator immediately diff --git a/python/copilot/types.py b/python/copilot/types.py index cc4913225..42006d6b4 100644 --- a/python/copilot/types.py +++ b/python/copilot/types.py @@ -134,6 +134,7 @@ class Tool: description: str handler: ToolHandler parameters: dict[str, Any] | None = None + overrides_built_in_tool: bool = False # System message configuration (discriminated union) diff --git a/python/e2e/test_tools.py b/python/e2e/test_tools.py index e25b23744..28c6ee20a 100644 --- a/python/e2e/test_tools.py +++ b/python/e2e/test_tools.py @@ -137,7 +137,7 @@ async def test_overrides_built_in_tool_with_custom_tool(self, ctx: E2ETestContex class GrepParams(BaseModel): query: str = Field(description="Search query") - @define_tool("grep", description="A custom grep implementation that overrides the built-in") + @define_tool("grep", description="A custom grep implementation that overrides the built-in", overrides_built_in_tool=True) def custom_grep(params: GrepParams, invocation: ToolInvocation) -> str: return f"CUSTOM_GREP_RESULT: {params.query}" diff --git a/python/test_client.py b/python/test_client.py index 7546b3544..bf8029a9c 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -176,9 +176,9 @@ def test_use_logged_in_user_with_cli_url_raises(self): ) -class TestExcludedToolsFromRegisteredTools: +class TestOverridesBuiltInTool: @pytest.mark.asyncio - async def test_tools_added_to_excluded_tools(self): + async def test_overrides_built_in_tool_sent_in_tool_definition(self): client = CopilotClient({"cli_path": CLI_PATH}) await client.start() @@ -192,17 +192,20 @@ async def mock_request(method, params): client._client.request = mock_request - @define_tool(description="Edit a file") - def edit_file(params) -> str: + @define_tool(description="Custom grep", overrides_built_in_tool=True) + def grep(params) -> str: return "ok" - await client.create_session({"tools": [edit_file]}) - assert "edit_file" in captured["session.create"]["excludedTools"] + await client.create_session({"tools": [grep], "on_permission_request": PermissionHandler.approve_all}) + tool_defs = captured["session.create"]["tools"] + assert len(tool_defs) == 1 + assert tool_defs[0]["name"] == "grep" + assert tool_defs[0]["overridesBuiltInTool"] is True finally: await client.force_stop() @pytest.mark.asyncio - async def test_deduplication_with_existing_excluded_tools(self): + async def test_does_not_merge_tool_names_into_excluded_tools(self): client = CopilotClient({"cli_path": CLI_PATH}) await client.start() @@ -216,24 +219,17 @@ async def mock_request(method, params): client._client.request = mock_request - @define_tool(description="Edit a file") - def edit_file(params) -> str: + @define_tool(description="Custom grep", overrides_built_in_tool=True) + def grep(params) -> str: return "ok" - await client.create_session( - { - "tools": [edit_file], - "excluded_tools": ["edit_file", "other_tool"], - } - ) - excluded = captured["session.create"]["excludedTools"] - assert excluded.count("edit_file") == 1 - assert "other_tool" in excluded + await client.create_session({"tools": [grep], "on_permission_request": PermissionHandler.approve_all}) + assert "excludedTools" not in captured["session.create"] finally: await client.force_stop() @pytest.mark.asyncio - async def test_no_excluded_tools_when_no_tools(self): + async def test_preserves_user_excluded_tools(self): client = CopilotClient({"cli_path": CLI_PATH}) await client.start() @@ -246,18 +242,26 @@ async def mock_request(method, params): return await original_request(method, params) client._client.request = mock_request - await client.create_session({}) - assert "excludedTools" not in captured["session.create"] + + @define_tool(description="Custom grep", overrides_built_in_tool=True) + def grep(params) -> str: + return "ok" + + await client.create_session( + {"tools": [grep], "excluded_tools": ["bash"], "on_permission_request": PermissionHandler.approve_all} + ) + excluded = captured["session.create"]["excludedTools"] + assert excluded == ["bash"] finally: await client.force_stop() @pytest.mark.asyncio - async def test_resume_session_adds_tools_to_excluded(self): + async def test_resume_session_sends_overrides_built_in_tool(self): client = CopilotClient({"cli_path": CLI_PATH}) await client.start() try: - session = await client.create_session({}) + session = await client.create_session({"on_permission_request": PermissionHandler.approve_all}) captured = {} original_request = client._client.request @@ -268,12 +272,14 @@ async def mock_request(method, params): client._client.request = mock_request - @define_tool(description="Edit a file") - def edit_file(params) -> str: + @define_tool(description="Custom grep", overrides_built_in_tool=True) + def grep(params) -> str: return "ok" - await client.resume_session(session.session_id, {"tools": [edit_file]}) - assert "edit_file" in captured["session.resume"]["excludedTools"] + await client.resume_session(session.session_id, {"tools": [grep], "on_permission_request": PermissionHandler.approve_all}) + tool_defs = captured["session.resume"]["tools"] + assert len(tool_defs) == 1 + assert tool_defs[0]["overridesBuiltInTool"] is True finally: await client.force_stop() diff --git a/test/scenarios/tools/tool-overrides/README.md b/test/scenarios/tools/tool-overrides/README.md index f33f22bc3..f4a3cd65f 100644 --- a/test/scenarios/tools/tool-overrides/README.md +++ b/test/scenarios/tools/tool-overrides/README.md @@ -1,10 +1,10 @@ # Config Sample: Tool Overrides -Demonstrates how registering a custom tool with the same name as a built-in tool automatically overrides the built-in. The SDK's `mergeExcludedTools` logic adds custom tool names to `excludedTools`, so the CLI uses your implementation instead. +Demonstrates how to override a built-in tool with a custom implementation using the `overridesBuiltInTool` flag. When this flag is set on a custom tool, the SDK knows to disable the corresponding built-in tool so your implementation is used instead. ## What Each Sample Does -1. Creates a session with a custom `grep` tool that returns `"CUSTOM_GREP_RESULT: "` +1. Creates a session with a custom `grep` tool (with `overridesBuiltInTool` enabled) that returns `"CUSTOM_GREP_RESULT: "` 2. Sends: _"Use grep to search for the word 'hello'"_ 3. Prints the response — which should contain `CUSTOM_GREP_RESULT` (proving the custom tool ran, not the built-in) @@ -12,9 +12,10 @@ Demonstrates how registering a custom tool with the same name as a built-in tool | Option | Value | Effect | |--------|-------|--------| -| `tools` | Custom `grep` tool | Overrides the built-in `grep` with a custom implementation | +| `tools` | Custom `grep` tool | Provides a custom grep implementation | +| `overridesBuiltInTool` | `true` | Tells the SDK to disable the built-in `grep` in favor of the custom one | -Behind the scenes, the SDK automatically adds `"grep"` to `excludedTools` so the CLI's built-in grep is disabled. +The flag is set per-tool in TypeScript (`overridesBuiltInTool: true`), Python (`overrides_built_in_tool=True`), and Go (`OverridesBuiltInTool: true`). In C#, built-in tool overrides are declared on the session config via `BuiltInToolOverrides`. ## Run diff --git a/test/scenarios/tools/tool-overrides/csharp/Program.cs b/test/scenarios/tools/tool-overrides/csharp/Program.cs index 438f53ce6..e72036401 100644 --- a/test/scenarios/tools/tool-overrides/csharp/Program.cs +++ b/test/scenarios/tools/tool-overrides/csharp/Program.cs @@ -17,6 +17,7 @@ Model = "claude-haiku-4.5", OnPermissionRequest = PermissionHandler.ApproveAll, Tools = [AIFunctionFactory.Create(CustomGrep, "grep")], + BuiltInToolOverrides = new HashSet { "grep" }, }); var response = await session.SendAndWaitAsync(new MessageOptions diff --git a/test/scenarios/tools/tool-overrides/go/main.go b/test/scenarios/tools/tool-overrides/go/main.go index f2f5119d3..8c152c20b 100644 --- a/test/scenarios/tools/tool-overrides/go/main.go +++ b/test/scenarios/tools/tool-overrides/go/main.go @@ -24,15 +24,16 @@ func main() { } defer client.Stop() + grepTool := copilot.DefineTool("grep", "A custom grep implementation that overrides the built-in", + func(params GrepParams, inv copilot.ToolInvocation) (string, error) { + return "CUSTOM_GREP_RESULT: " + params.Query, nil + }) + grepTool.OverridesBuiltInTool = true + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ Model: "claude-haiku-4.5", OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - Tools: []copilot.Tool{ - copilot.DefineTool("grep", "A custom grep implementation that overrides the built-in", - func(params GrepParams, inv copilot.ToolInvocation) (string, error) { - return "CUSTOM_GREP_RESULT: " + params.Query, nil - }), - }, + Tools: []copilot.Tool{grepTool}, }) if err != nil { log.Fatal(err) diff --git a/test/scenarios/tools/tool-overrides/python/main.py b/test/scenarios/tools/tool-overrides/python/main.py index 6e9e870f1..ef2ee43bd 100644 --- a/test/scenarios/tools/tool-overrides/python/main.py +++ b/test/scenarios/tools/tool-overrides/python/main.py @@ -10,7 +10,7 @@ class GrepParams(BaseModel): query: str = Field(description="Search query") -@define_tool("grep", description="A custom grep implementation that overrides the built-in") +@define_tool("grep", description="A custom grep implementation that overrides the built-in", overrides_built_in_tool=True) def custom_grep(params: GrepParams) -> str: return f"CUSTOM_GREP_RESULT: {params.query}" diff --git a/test/scenarios/tools/tool-overrides/typescript/src/index.ts b/test/scenarios/tools/tool-overrides/typescript/src/index.ts index a27d89eeb..d98a98df3 100644 --- a/test/scenarios/tools/tool-overrides/typescript/src/index.ts +++ b/test/scenarios/tools/tool-overrides/typescript/src/index.ts @@ -17,6 +17,7 @@ async function main() { parameters: z.object({ query: z.string().describe("Search query"), }), + overridesBuiltInTool: true, handler: ({ query }) => `CUSTOM_GREP_RESULT: ${query}`, }), ], From 6e56f47d2c1ac5ba47128aefcdcc5595afad4876 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 3 Mar 2026 15:25:26 +0000 Subject: [PATCH 08/10] chore: clean up leftover mergeExcludedTools artifacts - Remove TestMergeExcludedTools from go/client_test.go (tests deleted function) - Revert excluded_tools handling in python/copilot/client.py to 'is not None' - Rename MergeExcludedToolsTests.cs to OverridesBuiltInToolTests.cs to match class Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...sTests.cs => OverridesBuiltInToolTests.cs} | 0 go/client_test.go | 36 ------------------- python/copilot/client.py | 8 ++--- 3 files changed, 4 insertions(+), 40 deletions(-) rename dotnet/test/{MergeExcludedToolsTests.cs => OverridesBuiltInToolTests.cs} (100%) diff --git a/dotnet/test/MergeExcludedToolsTests.cs b/dotnet/test/OverridesBuiltInToolTests.cs similarity index 100% rename from dotnet/test/MergeExcludedToolsTests.cs rename to dotnet/test/OverridesBuiltInToolTests.cs diff --git a/go/client_test.go b/go/client_test.go index dc2fd2579..d791a5a30 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -569,39 +569,3 @@ func TestClient_StartStopRace(t *testing.T) { t.Fatal(err) } } - -func TestMergeExcludedTools(t *testing.T) { - t.Run("adds tool names to excluded tools", func(t *testing.T) { - tools := []Tool{{Name: "edit_file"}, {Name: "read_file"}} - got := mergeExcludedTools(nil, tools) - want := []string{"edit_file", "read_file"} - if !reflect.DeepEqual(got, want) { - t.Errorf("got %v, want %v", got, want) - } - }) - - t.Run("deduplicates with existing excluded tools", func(t *testing.T) { - excluded := []string{"edit_file", "run_shell"} - tools := []Tool{{Name: "edit_file"}, {Name: "read_file"}} - got := mergeExcludedTools(excluded, tools) - want := []string{"edit_file", "run_shell", "read_file"} - if !reflect.DeepEqual(got, want) { - t.Errorf("got %v, want %v", got, want) - } - }) - - t.Run("returns original list when no tools provided", func(t *testing.T) { - excluded := []string{"edit_file"} - got := mergeExcludedTools(excluded, nil) - if !reflect.DeepEqual(got, excluded) { - t.Errorf("got %v, want %v", got, excluded) - } - }) - - t.Run("returns nil when both inputs are empty", func(t *testing.T) { - got := mergeExcludedTools(nil, nil) - if got != nil { - t.Errorf("got %v, want nil", got) - } - }) -} diff --git a/python/copilot/client.py b/python/copilot/client.py index 93d661495..59bc9754c 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -498,8 +498,8 @@ async def create_session(self, config: SessionConfig) -> CopilotSession: available_tools = cfg.get("available_tools") if available_tools is not None: payload["availableTools"] = available_tools - excluded_tools = list(cfg.get("excluded_tools") or []) - if excluded_tools: + excluded_tools = cfg.get("excluded_tools") + if excluded_tools is not None: payload["excludedTools"] = excluded_tools # Always enable permission request callback (deny by default if no handler provided) @@ -678,8 +678,8 @@ async def resume_session(self, session_id: str, config: ResumeSessionConfig) -> if available_tools is not None: payload["availableTools"] = available_tools - excluded_tools = list(cfg.get("excluded_tools") or []) - if excluded_tools: + excluded_tools = cfg.get("excluded_tools") + if excluded_tools is not None: payload["excludedTools"] = excluded_tools provider = cfg.get("provider") From bf6c79287c21d15a3e3cc75c12b4cd4579b45c3b Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 3 Mar 2026 15:40:45 +0000 Subject: [PATCH 09/10] refactor: replace BuiltInToolOverrides with AdditionalProperties, remove stale tests - .NET: Read is_override from AIFunction.AdditionalProperties instead of BuiltInToolOverrides on session config. Remove BuiltInToolOverrides from SessionConfig and ResumeSessionConfig. - Remove negative tests asserting old mergeExcludedTools behavior is gone (Node: 'does not merge tool names', 'preserves user-specified excludedTools'; Python: test_does_not_merge_tool_names, test_preserves_user_excluded_tools) - Update README and scenario examples - Run formatters (gofmt, ruff, prettier, dotnet format) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/README.md | 15 +++-- dotnet/src/Client.cs | 15 +++-- dotnet/src/Types.cs | 16 ----- dotnet/test/OverridesBuiltInToolTests.cs | 38 +++-------- dotnet/test/ToolsTests.cs | 9 ++- go/internal/e2e/tools_test.go | 6 +- nodejs/test/client.test.ts | 53 ++++++--------- python/e2e/test_tools.py | 6 +- python/test_client.py | 64 +++---------------- test/scenarios/tools/tool-overrides/README.md | 2 +- .../tools/tool-overrides/csharp/Program.cs | 9 ++- 11 files changed, 77 insertions(+), 156 deletions(-) diff --git a/dotnet/README.md b/dotnet/README.md index 7d6ef60c6..e71be8eb0 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -417,22 +417,25 @@ When Copilot invokes `lookup_issue`, the client automatically runs your handler #### Overriding Built-in Tools -If you register a tool with the same name as a built-in CLI tool (e.g. `edit_file`, `read_file`), the SDK will throw an error unless you explicitly opt in by setting `BuiltInToolOverrides` on the session config. This flag signals that you intend to replace the built-in tool with your custom implementation. +If you register a tool with the same name as a built-in CLI tool (e.g. `edit_file`, `read_file`), the runtime will return an error unless you explicitly opt in by setting `is_override` in the tool's `AdditionalProperties`. This flag signals that you intend to replace the built-in tool with your custom implementation. ```csharp -// Register the custom tool -AIFunctionFactory.Create( +var editFile = AIFunctionFactory.Create( async ([Description("File path")] string path, [Description("New content")] string content) => { // your logic }, "edit_file", - "Custom file editor with project-specific validation") + "Custom file editor with project-specific validation", + new AIFunctionFactoryOptions + { + AdditionalProperties = new ReadOnlyDictionary( + new Dictionary { ["is_override"] = true }) + }); -// Opt in to overriding the built-in tool in session config var session = await client.CreateSessionAsync(new SessionConfig { Model = "gpt-5", - BuiltInToolOverrides = new HashSet { "edit_file" } + Tools = [editFile], }); ``` diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 0af65ad10..cf6c5a29d 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -386,8 +386,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance config.SessionId, config.ClientName, config.ReasoningEffort, - config.Tools?.Select(f => ToolDefinition.FromAIFunction(f, - config.BuiltInToolOverrides?.Contains(f.Name) == true)).ToList(), + config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), config.SystemMessage, config.AvailableTools, config.ExcludedTools, @@ -478,8 +477,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config.ClientName, config.Model, config.ReasoningEffort, - config.Tools?.Select(f => ToolDefinition.FromAIFunction(f, - config.BuiltInToolOverrides?.Contains(f.Name) == true)).ToList(), + config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), config.SystemMessage, config.AvailableTools, config.ExcludedTools, @@ -1421,9 +1419,12 @@ internal record ToolDefinition( JsonElement Parameters, /* JSON schema */ bool? OverridesBuiltInTool = null) { - public static ToolDefinition FromAIFunction(AIFunction function, bool overridesBuiltInTool = false) - => new ToolDefinition(function.Name, function.Description, function.JsonSchema, - overridesBuiltInTool ? true : null); + public static ToolDefinition FromAIFunction(AIFunction function) + { + var overrides = function.AdditionalProperties.TryGetValue("is_override", out var val) && val is true; + return new ToolDefinition(function.Name, function.Description, function.JsonSchema, + overrides ? true : null); + } } internal record CreateSessionResponse( diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 2948bb34d..97f5ebbbc 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -775,7 +775,6 @@ protected SessionConfig(SessionConfig? other) Streaming = other.Streaming; SystemMessage = other.SystemMessage; Tools = other.Tools is not null ? [.. other.Tools] : null; - BuiltInToolOverrides = other.BuiltInToolOverrides is not null ? [.. other.BuiltInToolOverrides] : null; WorkingDirectory = other.WorkingDirectory; } @@ -804,13 +803,6 @@ protected SessionConfig(SessionConfig? other) public ICollection? Tools { get; set; } - /// - /// Set of tool names that are intended to override built-in tools of the same name. - /// If a tool name clashes with a built-in tool and is not in this set, the runtime - /// will return an error. - /// - public HashSet? BuiltInToolOverrides { get; set; } - public SystemMessageConfig? SystemMessage { get; set; } public List? AvailableTools { get; set; } public List? ExcludedTools { get; set; } @@ -921,7 +913,6 @@ protected ResumeSessionConfig(ResumeSessionConfig? other) Streaming = other.Streaming; SystemMessage = other.SystemMessage; Tools = other.Tools is not null ? [.. other.Tools] : null; - BuiltInToolOverrides = other.BuiltInToolOverrides is not null ? [.. other.BuiltInToolOverrides] : null; WorkingDirectory = other.WorkingDirectory; } @@ -938,13 +929,6 @@ protected ResumeSessionConfig(ResumeSessionConfig? other) public ICollection? Tools { get; set; } - /// - /// Set of tool names that are intended to override built-in tools of the same name. - /// If a tool name clashes with a built-in tool and is not in this set, the runtime - /// will return an error. - /// - public HashSet? BuiltInToolOverrides { get; set; } - /// /// System message configuration. /// diff --git a/dotnet/test/OverridesBuiltInToolTests.cs b/dotnet/test/OverridesBuiltInToolTests.cs index 9455abf1c..7d483bc77 100644 --- a/dotnet/test/OverridesBuiltInToolTests.cs +++ b/dotnet/test/OverridesBuiltInToolTests.cs @@ -3,8 +3,8 @@ *--------------------------------------------------------------------------------------------*/ using Microsoft.Extensions.AI; +using System.Collections.ObjectModel; using System.ComponentModel; -using System.Text.Json; using Xunit; namespace GitHub.Copilot.SDK.Test; @@ -14,8 +14,13 @@ public class OverridesBuiltInToolTests [Fact] public void ToolDefinition_FromAIFunction_Sets_OverridesBuiltInTool() { - var fn = AIFunctionFactory.Create(Noop, "grep"); - var def = CopilotClient.ToolDefinition.FromAIFunction(fn, overridesBuiltInTool: true); + var fn = AIFunctionFactory.Create((Delegate)Noop, new AIFunctionFactoryOptions + { + Name = "grep", + AdditionalProperties = new ReadOnlyDictionary( + new Dictionary { ["is_override"] = true }) + }); + var def = CopilotClient.ToolDefinition.FromAIFunction(fn); Assert.Equal("grep", def.Name); Assert.True(def.OverridesBuiltInTool); @@ -25,37 +30,12 @@ public void ToolDefinition_FromAIFunction_Sets_OverridesBuiltInTool() public void ToolDefinition_FromAIFunction_Omits_OverridesBuiltInTool_When_False() { var fn = AIFunctionFactory.Create(Noop, "custom_tool"); - var def = CopilotClient.ToolDefinition.FromAIFunction(fn, overridesBuiltInTool: false); + var def = CopilotClient.ToolDefinition.FromAIFunction(fn); Assert.Equal("custom_tool", def.Name); Assert.Null(def.OverridesBuiltInTool); } - [Fact] - public void SessionConfig_BuiltInToolOverrides_Is_Used() - { - var config = new SessionConfig - { - Tools = new List { AIFunctionFactory.Create(Noop, "grep") }, - BuiltInToolOverrides = new HashSet { "grep" }, - }; - - Assert.Contains("grep", config.BuiltInToolOverrides); - } - - [Fact] - public void ResumeSessionConfig_BuiltInToolOverrides_Is_Used() - { - var config = new ResumeSessionConfig - { - Tools = new List { AIFunctionFactory.Create(Noop, "grep") }, - BuiltInToolOverrides = new HashSet { "grep" }, - }; - - Assert.NotNull(config.BuiltInToolOverrides); - Assert.Contains("grep", config.BuiltInToolOverrides!); - } - [Description("No-op")] static string Noop() => ""; } diff --git a/dotnet/test/ToolsTests.cs b/dotnet/test/ToolsTests.cs index 0ca98ee0d..1bb6b2cb2 100644 --- a/dotnet/test/ToolsTests.cs +++ b/dotnet/test/ToolsTests.cs @@ -4,6 +4,7 @@ using GitHub.Copilot.SDK.Test.Harness; using Microsoft.Extensions.AI; +using System.Collections.ObjectModel; using System.ComponentModel; using System.Linq; using System.Text.Json; @@ -157,8 +158,12 @@ public async Task Overrides_Built_In_Tool_With_Custom_Tool() { var session = await CreateSessionAsync(new SessionConfig { - Tools = [AIFunctionFactory.Create(CustomGrep, "grep")], - BuiltInToolOverrides = ["grep"], + Tools = [AIFunctionFactory.Create((Delegate)CustomGrep, new AIFunctionFactoryOptions + { + Name = "grep", + AdditionalProperties = new ReadOnlyDictionary( + new Dictionary { ["is_override"] = true }) + })], OnPermissionRequest = PermissionHandler.ApproveAll, }); diff --git a/go/internal/e2e/tools_test.go b/go/internal/e2e/tools_test.go index 342f78de5..310a88ee7 100644 --- a/go/internal/e2e/tools_test.go +++ b/go/internal/e2e/tools_test.go @@ -272,9 +272,9 @@ func TestTools(t *testing.T) { } grepTool := copilot.DefineTool("grep", "A custom grep implementation that overrides the built-in", - func(params GrepParams, inv copilot.ToolInvocation) (string, error) { - return "CUSTOM_GREP_RESULT: " + params.Query, nil - }) + func(params GrepParams, inv copilot.ToolInvocation) (string, error) { + return "CUSTOM_GREP_RESULT: " + params.Query, nil + }) grepTool.OverridesBuiltInTool = true session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 41dbc8743..f9618b6dd 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -303,46 +303,22 @@ describe("CopilotClient", () => { const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ onPermissionRequest: approveAll, - tools: [{ name: "grep", description: "custom grep", handler: async () => "ok", overridesBuiltInTool: true }], + tools: [ + { + name: "grep", + description: "custom grep", + handler: async () => "ok", + overridesBuiltInTool: true, + }, + ], }); const payload = spy.mock.calls.find((c) => c[0] === "session.create")![1] as any; expect(payload.tools).toEqual([ - expect.objectContaining({ name: "grep", overridesBuiltInTool: true }) + expect.objectContaining({ name: "grep", overridesBuiltInTool: true }), ]); }); - it("does not merge tool names into excludedTools", 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, - tools: [{ name: "grep", description: "custom grep", handler: async () => "ok", overridesBuiltInTool: true }], - }); - - const payload = spy.mock.calls.find((c) => c[0] === "session.create")![1] as any; - expect(payload.excludedTools).toBeUndefined(); - }); - - it("preserves user-specified excludedTools without adding tool names", 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, - tools: [{ name: "grep", description: "custom grep", handler: async () => "ok", overridesBuiltInTool: true }], - excludedTools: ["bash"], - }); - - const payload = spy.mock.calls.find((c) => c[0] === "session.create")![1] as any; - expect(payload.excludedTools).toEqual(["bash"]); - }); - it("sends overridesBuiltInTool in tool definition on session.resume", async () => { const client = new CopilotClient(); await client.start(); @@ -352,12 +328,19 @@ describe("CopilotClient", () => { const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.resumeSession(session.sessionId, { onPermissionRequest: approveAll, - tools: [{ name: "grep", description: "custom grep", handler: async () => "ok", overridesBuiltInTool: true }], + tools: [ + { + name: "grep", + description: "custom grep", + handler: async () => "ok", + overridesBuiltInTool: true, + }, + ], }); const payload = spy.mock.calls.find((c) => c[0] === "session.resume")![1] as any; expect(payload.tools).toEqual([ - expect.objectContaining({ name: "grep", overridesBuiltInTool: true }) + expect.objectContaining({ name: "grep", overridesBuiltInTool: true }), ]); }); }); diff --git a/python/e2e/test_tools.py b/python/e2e/test_tools.py index 28c6ee20a..35400464f 100644 --- a/python/e2e/test_tools.py +++ b/python/e2e/test_tools.py @@ -137,7 +137,11 @@ async def test_overrides_built_in_tool_with_custom_tool(self, ctx: E2ETestContex class GrepParams(BaseModel): query: str = Field(description="Search query") - @define_tool("grep", description="A custom grep implementation that overrides the built-in", overrides_built_in_tool=True) + @define_tool( + "grep", + description="A custom grep implementation that overrides the built-in", + overrides_built_in_tool=True, + ) def custom_grep(params: GrepParams, invocation: ToolInvocation) -> str: return f"CUSTOM_GREP_RESULT: {params.query}" diff --git a/python/test_client.py b/python/test_client.py index bf8029a9c..05b324228 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -196,7 +196,9 @@ async def mock_request(method, params): def grep(params) -> str: return "ok" - await client.create_session({"tools": [grep], "on_permission_request": PermissionHandler.approve_all}) + await client.create_session( + {"tools": [grep], "on_permission_request": PermissionHandler.approve_all} + ) tool_defs = captured["session.create"]["tools"] assert len(tool_defs) == 1 assert tool_defs[0]["name"] == "grep" @@ -204,64 +206,15 @@ def grep(params) -> str: finally: await client.force_stop() - @pytest.mark.asyncio - async def test_does_not_merge_tool_names_into_excluded_tools(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 - - @define_tool(description="Custom grep", overrides_built_in_tool=True) - def grep(params) -> str: - return "ok" - - await client.create_session({"tools": [grep], "on_permission_request": PermissionHandler.approve_all}) - assert "excludedTools" not in captured["session.create"] - finally: - await client.force_stop() - - @pytest.mark.asyncio - async def test_preserves_user_excluded_tools(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 - - @define_tool(description="Custom grep", overrides_built_in_tool=True) - def grep(params) -> str: - return "ok" - - await client.create_session( - {"tools": [grep], "excluded_tools": ["bash"], "on_permission_request": PermissionHandler.approve_all} - ) - excluded = captured["session.create"]["excludedTools"] - assert excluded == ["bash"] - finally: - await client.force_stop() - @pytest.mark.asyncio async def test_resume_session_sends_overrides_built_in_tool(self): client = CopilotClient({"cli_path": CLI_PATH}) await client.start() try: - session = await client.create_session({"on_permission_request": PermissionHandler.approve_all}) + session = await client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) captured = {} original_request = client._client.request @@ -276,7 +229,10 @@ async def mock_request(method, params): def grep(params) -> str: return "ok" - await client.resume_session(session.session_id, {"tools": [grep], "on_permission_request": PermissionHandler.approve_all}) + await client.resume_session( + session.session_id, + {"tools": [grep], "on_permission_request": PermissionHandler.approve_all}, + ) tool_defs = captured["session.resume"]["tools"] assert len(tool_defs) == 1 assert tool_defs[0]["overridesBuiltInTool"] is True diff --git a/test/scenarios/tools/tool-overrides/README.md b/test/scenarios/tools/tool-overrides/README.md index f4a3cd65f..45f75dc86 100644 --- a/test/scenarios/tools/tool-overrides/README.md +++ b/test/scenarios/tools/tool-overrides/README.md @@ -15,7 +15,7 @@ Demonstrates how to override a built-in tool with a custom implementation using | `tools` | Custom `grep` tool | Provides a custom grep implementation | | `overridesBuiltInTool` | `true` | Tells the SDK to disable the built-in `grep` in favor of the custom one | -The flag is set per-tool in TypeScript (`overridesBuiltInTool: true`), Python (`overrides_built_in_tool=True`), and Go (`OverridesBuiltInTool: true`). In C#, built-in tool overrides are declared on the session config via `BuiltInToolOverrides`. +The flag is set per-tool in TypeScript (`overridesBuiltInTool: true`), Python (`overrides_built_in_tool=True`), and Go (`OverridesBuiltInTool: true`). In C#, set `is_override` in the tool's `AdditionalProperties` via `AIFunctionFactoryOptions`. ## Run diff --git a/test/scenarios/tools/tool-overrides/csharp/Program.cs b/test/scenarios/tools/tool-overrides/csharp/Program.cs index e72036401..42ad433fe 100644 --- a/test/scenarios/tools/tool-overrides/csharp/Program.cs +++ b/test/scenarios/tools/tool-overrides/csharp/Program.cs @@ -1,3 +1,4 @@ +using System.Collections.ObjectModel; using System.ComponentModel; using GitHub.Copilot.SDK; using Microsoft.Extensions.AI; @@ -16,8 +17,12 @@ { Model = "claude-haiku-4.5", OnPermissionRequest = PermissionHandler.ApproveAll, - Tools = [AIFunctionFactory.Create(CustomGrep, "grep")], - BuiltInToolOverrides = new HashSet { "grep" }, + Tools = [AIFunctionFactory.Create((Delegate)CustomGrep, new AIFunctionFactoryOptions + { + Name = "grep", + AdditionalProperties = new ReadOnlyDictionary( + new Dictionary { ["is_override"] = true }) + })], }); var response = await session.SendAndWaitAsync(new MessageOptions From bf8840bdb73d6beeaf7ab0412290f8cff617e99c Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 3 Mar 2026 15:45:52 +0000 Subject: [PATCH 10/10] chore: remove InternalsVisibleTo and redundant unit test, fix overload signatures - Remove OverridesBuiltInToolTests.cs (E2E test in ToolsTests.cs covers this) - Remove InternalsVisibleTo from csproj (no longer needed) - Add overrides_built_in_tool param to both define_tool overload signatures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/GitHub.Copilot.SDK.csproj | 4 --- dotnet/test/OverridesBuiltInToolTests.cs | 41 ------------------------ python/copilot/tools.py | 2 ++ 3 files changed, 2 insertions(+), 45 deletions(-) delete mode 100644 dotnet/test/OverridesBuiltInToolTests.cs diff --git a/dotnet/src/GitHub.Copilot.SDK.csproj b/dotnet/src/GitHub.Copilot.SDK.csproj index 7a3fdacaf..019788cfa 100644 --- a/dotnet/src/GitHub.Copilot.SDK.csproj +++ b/dotnet/src/GitHub.Copilot.SDK.csproj @@ -17,10 +17,6 @@ true - - - - diff --git a/dotnet/test/OverridesBuiltInToolTests.cs b/dotnet/test/OverridesBuiltInToolTests.cs deleted file mode 100644 index 7d483bc77..000000000 --- a/dotnet/test/OverridesBuiltInToolTests.cs +++ /dev/null @@ -1,41 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - *--------------------------------------------------------------------------------------------*/ - -using Microsoft.Extensions.AI; -using System.Collections.ObjectModel; -using System.ComponentModel; -using Xunit; - -namespace GitHub.Copilot.SDK.Test; - -public class OverridesBuiltInToolTests -{ - [Fact] - public void ToolDefinition_FromAIFunction_Sets_OverridesBuiltInTool() - { - var fn = AIFunctionFactory.Create((Delegate)Noop, new AIFunctionFactoryOptions - { - Name = "grep", - AdditionalProperties = new ReadOnlyDictionary( - new Dictionary { ["is_override"] = true }) - }); - var def = CopilotClient.ToolDefinition.FromAIFunction(fn); - - Assert.Equal("grep", def.Name); - Assert.True(def.OverridesBuiltInTool); - } - - [Fact] - public void ToolDefinition_FromAIFunction_Omits_OverridesBuiltInTool_When_False() - { - var fn = AIFunctionFactory.Create(Noop, "custom_tool"); - var def = CopilotClient.ToolDefinition.FromAIFunction(fn); - - Assert.Equal("custom_tool", def.Name); - Assert.Null(def.OverridesBuiltInTool); - } - - [Description("No-op")] - static string Noop() => ""; -} diff --git a/python/copilot/tools.py b/python/copilot/tools.py index 0b80323d7..af32bd04f 100644 --- a/python/copilot/tools.py +++ b/python/copilot/tools.py @@ -25,6 +25,7 @@ def define_tool( name: str | None = None, *, description: str | None = None, + overrides_built_in_tool: bool = False, ) -> Callable[[Callable[..., Any]], Tool]: ... @@ -35,6 +36,7 @@ def define_tool( description: str | None = None, handler: Callable[[T, ToolInvocation], R], params_type: type[T], + overrides_built_in_tool: bool = False, ) -> Tool: ...