Skip to content

Commit 17b567b

Browse files
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>
1 parent b49777e commit 17b567b

26 files changed

Lines changed: 222 additions & 185 deletions

File tree

dotnet/README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,15 +417,23 @@ When Copilot invokes `lookup_issue`, the client automatically runs your handler
417417

418418
#### Overriding Built-in Tools
419419

420-
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.
420+
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.
421421

422422
```csharp
423+
// Register the custom tool
423424
AIFunctionFactory.Create(
424425
async ([Description("File path")] string path, [Description("New content")] string content) => {
425426
// your logic
426427
},
427428
"edit_file",
428429
"Custom file editor with project-specific validation")
430+
431+
// Opt in to overriding the built-in tool in session config
432+
var session = await client.CreateSessionAsync(new SessionConfig
433+
{
434+
Model = "gpt-5",
435+
BuiltInToolOverrides = new HashSet<string> { "edit_file" }
436+
});
429437
```
430438

431439
### System Message Customization

dotnet/src/Client.cs

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -386,10 +386,11 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
386386
config.SessionId,
387387
config.ClientName,
388388
config.ReasoningEffort,
389-
config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(),
389+
config.Tools?.Select(f => ToolDefinition.FromAIFunction(f,
390+
config.BuiltInToolOverrides?.Contains(f.Name) == true)).ToList(),
390391
config.SystemMessage,
391392
config.AvailableTools,
392-
MergeExcludedTools(config.ExcludedTools, config.Tools),
393+
config.ExcludedTools,
393394
config.Provider,
394395
(bool?)true,
395396
config.OnUserInputRequest != null ? true : null,
@@ -477,10 +478,11 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
477478
config.ClientName,
478479
config.Model,
479480
config.ReasoningEffort,
480-
config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(),
481+
config.Tools?.Select(f => ToolDefinition.FromAIFunction(f,
482+
config.BuiltInToolOverrides?.Contains(f.Name) == true)).ToList(),
481483
config.SystemMessage,
482484
config.AvailableTools,
483-
MergeExcludedTools(config.ExcludedTools, config.Tools),
485+
config.ExcludedTools,
484486
config.Provider,
485487
(bool?)true,
486488
config.OnUserInputRequest != null ? true : null,
@@ -862,14 +864,6 @@ private void DispatchLifecycleEvent(SessionLifecycleEvent evt)
862864
}
863865
}
864866

865-
internal static List<string>? MergeExcludedTools(List<string>? excludedTools, ICollection<AIFunction>? tools)
866-
{
867-
var toolNames = tools?.Select(t => t.Name).ToList();
868-
if (toolNames is null or { Count: 0 }) return excludedTools;
869-
if (excludedTools is null or { Count: 0 }) return toolNames;
870-
return excludedTools.Union(toolNames).ToList();
871-
}
872-
873867
internal static async Task<T> InvokeRpcAsync<T>(JsonRpc rpc, string method, object?[]? args, CancellationToken cancellationToken)
874868
{
875869
return await InvokeRpcAsync<T>(rpc, method, args, null, cancellationToken);
@@ -1424,10 +1418,12 @@ internal record CreateSessionRequest(
14241418
internal record ToolDefinition(
14251419
string Name,
14261420
string? Description,
1427-
JsonElement Parameters /* JSON schema */)
1421+
JsonElement Parameters, /* JSON schema */
1422+
bool? OverridesBuiltInTool = null)
14281423
{
1429-
public static ToolDefinition FromAIFunction(AIFunction function)
1430-
=> new ToolDefinition(function.Name, function.Description, function.JsonSchema);
1424+
public static ToolDefinition FromAIFunction(AIFunction function, bool overridesBuiltInTool = false)
1425+
=> new ToolDefinition(function.Name, function.Description, function.JsonSchema,
1426+
overridesBuiltInTool ? true : null);
14311427
}
14321428

14331429
internal record CreateSessionResponse(

dotnet/src/Types.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -775,6 +775,7 @@ protected SessionConfig(SessionConfig? other)
775775
Streaming = other.Streaming;
776776
SystemMessage = other.SystemMessage;
777777
Tools = other.Tools is not null ? [.. other.Tools] : null;
778+
BuiltInToolOverrides = other.BuiltInToolOverrides is not null ? [.. other.BuiltInToolOverrides] : null;
778779
WorkingDirectory = other.WorkingDirectory;
779780
}
780781

@@ -802,6 +803,14 @@ protected SessionConfig(SessionConfig? other)
802803
public string? ConfigDir { get; set; }
803804

804805
public ICollection<AIFunction>? Tools { get; set; }
806+
807+
/// <summary>
808+
/// Set of tool names that are intended to override built-in tools of the same name.
809+
/// If a tool name clashes with a built-in tool and is not in this set, the runtime
810+
/// will return an error.
811+
/// </summary>
812+
public HashSet<string>? BuiltInToolOverrides { get; set; }
813+
805814
public SystemMessageConfig? SystemMessage { get; set; }
806815
public List<string>? AvailableTools { get; set; }
807816
public List<string>? ExcludedTools { get; set; }
@@ -912,6 +921,7 @@ protected ResumeSessionConfig(ResumeSessionConfig? other)
912921
Streaming = other.Streaming;
913922
SystemMessage = other.SystemMessage;
914923
Tools = other.Tools is not null ? [.. other.Tools] : null;
924+
BuiltInToolOverrides = other.BuiltInToolOverrides is not null ? [.. other.BuiltInToolOverrides] : null;
915925
WorkingDirectory = other.WorkingDirectory;
916926
}
917927

@@ -928,6 +938,13 @@ protected ResumeSessionConfig(ResumeSessionConfig? other)
928938

929939
public ICollection<AIFunction>? Tools { get; set; }
930940

941+
/// <summary>
942+
/// Set of tool names that are intended to override built-in tools of the same name.
943+
/// If a tool name clashes with a built-in tool and is not in this set, the runtime
944+
/// will return an error.
945+
/// </summary>
946+
public HashSet<string>? BuiltInToolOverrides { get; set; }
947+
931948
/// <summary>
932949
/// System message configuration.
933950
/// </summary>

dotnet/test/MergeExcludedToolsTests.cs

Lines changed: 25 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -4,74 +4,56 @@
44

55
using Microsoft.Extensions.AI;
66
using System.ComponentModel;
7+
using System.Text.Json;
78
using Xunit;
89

910
namespace GitHub.Copilot.SDK.Test;
1011

11-
public class MergeExcludedToolsTests
12+
public class OverridesBuiltInToolTests
1213
{
1314
[Fact]
14-
public void Tool_Names_Are_Added_To_ExcludedTools()
15+
public void ToolDefinition_FromAIFunction_Sets_OverridesBuiltInTool()
1516
{
16-
var tools = new List<AIFunction>
17-
{
18-
AIFunctionFactory.Create(Noop, "my_tool"),
19-
};
17+
var fn = AIFunctionFactory.Create(Noop, "grep");
18+
var def = CopilotClient.ToolDefinition.FromAIFunction(fn, overridesBuiltInTool: true);
2019

21-
var result = CopilotClient.MergeExcludedTools(null, tools);
22-
23-
Assert.NotNull(result);
24-
Assert.Contains("my_tool", result!);
20+
Assert.Equal("grep", def.Name);
21+
Assert.True(def.OverridesBuiltInTool);
2522
}
2623

2724
[Fact]
28-
public void Merges_With_Existing_ExcludedTools_And_Deduplicates()
25+
public void ToolDefinition_FromAIFunction_Omits_OverridesBuiltInTool_When_False()
2926
{
30-
var existing = new List<string> { "view", "my_tool" };
31-
var tools = new List<AIFunction>
32-
{
33-
AIFunctionFactory.Create(Noop, "my_tool"),
34-
AIFunctionFactory.Create(Noop, "another_tool"),
35-
};
36-
37-
var result = CopilotClient.MergeExcludedTools(existing, tools);
27+
var fn = AIFunctionFactory.Create(Noop, "custom_tool");
28+
var def = CopilotClient.ToolDefinition.FromAIFunction(fn, overridesBuiltInTool: false);
3829

39-
Assert.NotNull(result);
40-
Assert.Equal(3, result!.Count);
41-
Assert.Contains("view", result);
42-
Assert.Contains("my_tool", result);
43-
Assert.Contains("another_tool", result);
30+
Assert.Equal("custom_tool", def.Name);
31+
Assert.Null(def.OverridesBuiltInTool);
4432
}
4533

4634
[Fact]
47-
public void Returns_Null_When_No_Tools_Provided()
35+
public void SessionConfig_BuiltInToolOverrides_Is_Used()
4836
{
49-
var result = CopilotClient.MergeExcludedTools(null, null);
50-
Assert.Null(result);
51-
}
52-
53-
[Fact]
54-
public void Returns_ExcludedTools_Unchanged_When_Tools_Empty()
55-
{
56-
var existing = new List<string> { "view" };
57-
var result = CopilotClient.MergeExcludedTools(existing, new List<AIFunction>());
37+
var config = new SessionConfig
38+
{
39+
Tools = new List<AIFunction> { AIFunctionFactory.Create(Noop, "grep") },
40+
BuiltInToolOverrides = new HashSet<string> { "grep" },
41+
};
5842

59-
Assert.Same(existing, result);
43+
Assert.Contains("grep", config.BuiltInToolOverrides);
6044
}
6145

6246
[Fact]
63-
public void Returns_Tool_Names_When_ExcludedTools_Null()
47+
public void ResumeSessionConfig_BuiltInToolOverrides_Is_Used()
6448
{
65-
var tools = new List<AIFunction>
49+
var config = new ResumeSessionConfig
6650
{
67-
AIFunctionFactory.Create(Noop, "my_tool"),
51+
Tools = new List<AIFunction> { AIFunctionFactory.Create(Noop, "grep") },
52+
BuiltInToolOverrides = new HashSet<string> { "grep" },
6853
};
6954

70-
var result = CopilotClient.MergeExcludedTools(null, tools);
71-
72-
Assert.NotNull(result);
73-
Assert.Single(result!);
74-
Assert.Equal("my_tool", result[0]);
55+
Assert.NotNull(config.BuiltInToolOverrides);
56+
Assert.Contains("grep", config.BuiltInToolOverrides!);
7557
}
7658

7759
[Description("No-op")]

dotnet/test/ToolsTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ public async Task Overrides_Built_In_Tool_With_Custom_Tool()
158158
var session = await CreateSessionAsync(new SessionConfig
159159
{
160160
Tools = [AIFunctionFactory.Create(CustomGrep, "grep")],
161+
BuiltInToolOverrides = ["grep"],
161162
OnPermissionRequest = PermissionHandler.ApproveAll,
162163
});
163164

go/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,13 +269,14 @@ When the model selects a tool, the SDK automatically runs your handler (in paral
269269

270270
#### Overriding Built-in Tools
271271

272-
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.
272+
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.
273273

274274
```go
275275
editFile := copilot.DefineTool("edit_file", "Custom file editor with project-specific validation",
276276
func(params EditFileParams, inv copilot.ToolInvocation) (any, error) {
277277
// your logic
278278
})
279+
editFile.OverridesBuiltInTool = true
279280
```
280281

281282
## Streaming

go/client.go

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -491,7 +491,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses
491491
req.Tools = config.Tools
492492
req.SystemMessage = config.SystemMessage
493493
req.AvailableTools = config.AvailableTools
494-
req.ExcludedTools = mergeExcludedTools(config.ExcludedTools, config.Tools)
494+
req.ExcludedTools = config.ExcludedTools
495495
req.Provider = config.Provider
496496
req.WorkingDirectory = config.WorkingDirectory
497497
req.MCPServers = config.MCPServers
@@ -588,7 +588,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string,
588588
req.Tools = config.Tools
589589
req.Provider = config.Provider
590590
req.AvailableTools = config.AvailableTools
591-
req.ExcludedTools = mergeExcludedTools(config.ExcludedTools, config.Tools)
591+
req.ExcludedTools = config.ExcludedTools
592592
if config.Streaming {
593593
req.Streaming = Bool(true)
594594
}
@@ -1410,29 +1410,6 @@ func buildFailedToolResult(internalError string) ToolResult {
14101410
}
14111411
}
14121412

1413-
// mergeExcludedTools returns a deduplicated list combining excludedTools with
1414-
// the names of any SDK-registered tools, so the CLI won't handle them.
1415-
func mergeExcludedTools(excludedTools []string, tools []Tool) []string {
1416-
if len(tools) == 0 {
1417-
return excludedTools
1418-
}
1419-
seen := make(map[string]bool, len(excludedTools)+len(tools))
1420-
merged := make([]string, 0, len(excludedTools)+len(tools))
1421-
for _, name := range excludedTools {
1422-
if !seen[name] {
1423-
seen[name] = true
1424-
merged = append(merged, name)
1425-
}
1426-
}
1427-
for _, t := range tools {
1428-
if !seen[t.Name] {
1429-
seen[t.Name] = true
1430-
merged = append(merged, t.Name)
1431-
}
1432-
}
1433-
return merged
1434-
}
1435-
14361413
// buildUnsupportedToolResult creates a failure ToolResult for an unsupported tool.
14371414
func buildUnsupportedToolResult(toolName string) ToolResult {
14381415
return ToolResult{

go/client_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,47 @@ func TestResumeSessionRequest_ClientName(t *testing.T) {
448448
})
449449
}
450450

451+
func TestOverridesBuiltInTool(t *testing.T) {
452+
t.Run("OverridesBuiltInTool is serialized in tool definition", func(t *testing.T) {
453+
tool := Tool{
454+
Name: "grep",
455+
Description: "Custom grep",
456+
OverridesBuiltInTool: true,
457+
Handler: func(_ ToolInvocation) (ToolResult, error) { return ToolResult{}, nil },
458+
}
459+
data, err := json.Marshal(tool)
460+
if err != nil {
461+
t.Fatalf("failed to marshal: %v", err)
462+
}
463+
var m map[string]any
464+
if err := json.Unmarshal(data, &m); err != nil {
465+
t.Fatalf("failed to unmarshal: %v", err)
466+
}
467+
if v, ok := m["overridesBuiltInTool"]; !ok || v != true {
468+
t.Errorf("expected overridesBuiltInTool=true, got %v", m)
469+
}
470+
})
471+
472+
t.Run("OverridesBuiltInTool omitted when false", func(t *testing.T) {
473+
tool := Tool{
474+
Name: "custom_tool",
475+
Description: "A custom tool",
476+
Handler: func(_ ToolInvocation) (ToolResult, error) { return ToolResult{}, nil },
477+
}
478+
data, err := json.Marshal(tool)
479+
if err != nil {
480+
t.Fatalf("failed to marshal: %v", err)
481+
}
482+
var m map[string]any
483+
if err := json.Unmarshal(data, &m); err != nil {
484+
t.Fatalf("failed to unmarshal: %v", err)
485+
}
486+
if _, ok := m["overridesBuiltInTool"]; ok {
487+
t.Errorf("expected overridesBuiltInTool to be omitted, got %v", m)
488+
}
489+
})
490+
}
491+
451492
func TestClient_CreateSession_RequiresPermissionHandler(t *testing.T) {
452493
t.Run("returns error when config is nil", func(t *testing.T) {
453494
client := NewClient(nil)

go/internal/e2e/tools_test.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -271,13 +271,16 @@ func TestTools(t *testing.T) {
271271
Query string `json:"query" jsonschema:"Search query"`
272272
}
273273

274+
grepTool := copilot.DefineTool("grep", "A custom grep implementation that overrides the built-in",
275+
func(params GrepParams, inv copilot.ToolInvocation) (string, error) {
276+
return "CUSTOM_GREP_RESULT: " + params.Query, nil
277+
})
278+
grepTool.OverridesBuiltInTool = true
279+
274280
session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{
275281
OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
276282
Tools: []copilot.Tool{
277-
copilot.DefineTool("grep", "A custom grep implementation that overrides the built-in",
278-
func(params GrepParams, inv copilot.ToolInvocation) (string, error) {
279-
return "CUSTOM_GREP_RESULT: " + params.Query, nil
280-
}),
283+
grepTool,
281284
},
282285
})
283286
if err != nil {

go/types.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -410,10 +410,11 @@ type SessionConfig struct {
410410

411411
// Tool describes a caller-implemented tool that can be invoked by Copilot
412412
type Tool struct {
413-
Name string `json:"name"`
414-
Description string `json:"description,omitempty"`
415-
Parameters map[string]any `json:"parameters,omitempty"`
416-
Handler ToolHandler `json:"-"`
413+
Name string `json:"name"`
414+
Description string `json:"description,omitempty"`
415+
Parameters map[string]any `json:"parameters,omitempty"`
416+
OverridesBuiltInTool bool `json:"overridesBuiltInTool,omitempty"`
417+
Handler ToolHandler `json:"-"`
417418
}
418419

419420
// ToolInvocation describes a tool call initiated by Copilot

0 commit comments

Comments
 (0)