Skip to content

Commit e277552

Browse files
Copilotpatniko
andcommitted
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>
1 parent 87cc7f0 commit e277552

File tree

13 files changed

+395
-9
lines changed

13 files changed

+395
-9
lines changed

dotnet/README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,19 @@ var session = await client.CreateSessionAsync(new SessionConfig
415415

416416
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.
417417

418+
#### Overriding Built-in Tools
419+
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.
421+
422+
```csharp
423+
AIFunctionFactory.Create(
424+
async ([Description("File path")] string path, [Description("New content")] string content) => {
425+
// your logic
426+
},
427+
"edit_file",
428+
"Custom file editor with project-specific validation")
429+
```
430+
418431
### System Message Customization
419432

420433
Control the system prompt using `SystemMessage` in session config:

dotnet/src/Client.cs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig? config = nul
382382
config?.Tools?.Select(ToolDefinition.FromAIFunction).ToList(),
383383
config?.SystemMessage,
384384
config?.AvailableTools,
385-
config?.ExcludedTools,
385+
MergeExcludedTools(config?.ExcludedTools, config?.Tools),
386386
config?.Provider,
387387
(bool?)true,
388388
config?.OnUserInputRequest != null ? true : null,
@@ -467,7 +467,7 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
467467
config?.Tools?.Select(ToolDefinition.FromAIFunction).ToList(),
468468
config?.SystemMessage,
469469
config?.AvailableTools,
470-
config?.ExcludedTools,
470+
MergeExcludedTools(config?.ExcludedTools, config?.Tools),
471471
config?.Provider,
472472
(bool?)true,
473473
config?.OnUserInputRequest != null ? true : null,
@@ -852,6 +852,14 @@ private void DispatchLifecycleEvent(SessionLifecycleEvent evt)
852852
}
853853
}
854854

855+
internal static List<string>? MergeExcludedTools(List<string>? excludedTools, ICollection<AIFunction>? tools)
856+
{
857+
var toolNames = tools?.Select(t => t.Name).ToList();
858+
if (toolNames is null or { Count: 0 }) return excludedTools;
859+
if (excludedTools is null or { Count: 0 }) return toolNames;
860+
return excludedTools.Union(toolNames).ToList();
861+
}
862+
855863
internal static async Task<T> InvokeRpcAsync<T>(JsonRpc rpc, string method, object?[]? args, CancellationToken cancellationToken)
856864
{
857865
return await InvokeRpcAsync<T>(rpc, method, args, null, cancellationToken);

dotnet/src/GitHub.Copilot.SDK.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
<IsAotCompatible>true</IsAotCompatible>
1818
</PropertyGroup>
1919

20+
<ItemGroup>
21+
<InternalsVisibleTo Include="GitHub.Copilot.SDK.Test" />
22+
</ItemGroup>
23+
2024
<ItemGroup>
2125
<None Include="../README.md" Pack="true" PackagePath="/" />
2226
</ItemGroup>
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
using Microsoft.Extensions.AI;
6+
using System.ComponentModel;
7+
using Xunit;
8+
9+
namespace GitHub.Copilot.SDK.Test;
10+
11+
public class MergeExcludedToolsTests
12+
{
13+
[Fact]
14+
public void Tool_Names_Are_Added_To_ExcludedTools()
15+
{
16+
var tools = new List<AIFunction>
17+
{
18+
AIFunctionFactory.Create(Noop, "my_tool"),
19+
};
20+
21+
var result = CopilotClient.MergeExcludedTools(null, tools);
22+
23+
Assert.NotNull(result);
24+
Assert.Contains("my_tool", result!);
25+
}
26+
27+
[Fact]
28+
public void Merges_With_Existing_ExcludedTools_And_Deduplicates()
29+
{
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);
38+
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);
44+
}
45+
46+
[Fact]
47+
public void Returns_Null_When_No_Tools_Provided()
48+
{
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>());
58+
59+
Assert.Same(existing, result);
60+
}
61+
62+
[Fact]
63+
public void Returns_Tool_Names_When_ExcludedTools_Null()
64+
{
65+
var tools = new List<AIFunction>
66+
{
67+
AIFunctionFactory.Create(Noop, "my_tool"),
68+
};
69+
70+
var result = CopilotClient.MergeExcludedTools(null, tools);
71+
72+
Assert.NotNull(result);
73+
Assert.Single(result!);
74+
Assert.Equal("my_tool", result[0]);
75+
}
76+
77+
[Description("No-op")]
78+
static string Noop() => "";
79+
}

go/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,17 @@ session, _ := client.CreateSession(context.Background(), &copilot.SessionConfig{
266266

267267
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.
268268

269+
#### Overriding Built-in Tools
270+
271+
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+
273+
```go
274+
editFile := copilot.DefineTool("edit_file", "Custom file editor with project-specific validation",
275+
func(params EditFileParams, inv copilot.ToolInvocation) (any, error) {
276+
// your logic
277+
})
278+
```
279+
269280
## Streaming
270281

271282
Enable streaming to receive assistant response chunks as they're generated:

go/client.go

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses
461461
req.Tools = config.Tools
462462
req.SystemMessage = config.SystemMessage
463463
req.AvailableTools = config.AvailableTools
464-
req.ExcludedTools = config.ExcludedTools
464+
req.ExcludedTools = mergeExcludedTools(config.ExcludedTools, config.Tools)
465465
req.Provider = config.Provider
466466
req.WorkingDirectory = config.WorkingDirectory
467467
req.MCPServers = config.MCPServers
@@ -558,7 +558,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string,
558558
req.Tools = config.Tools
559559
req.Provider = config.Provider
560560
req.AvailableTools = config.AvailableTools
561-
req.ExcludedTools = config.ExcludedTools
561+
req.ExcludedTools = mergeExcludedTools(config.ExcludedTools, config.Tools)
562562
if config.Streaming {
563563
req.Streaming = Bool(true)
564564
}
@@ -1352,6 +1352,29 @@ func buildFailedToolResult(internalError string) ToolResult {
13521352
}
13531353

13541354
// buildUnsupportedToolResult creates a failure ToolResult for an unsupported tool.
1355+
// mergeExcludedTools returns a deduplicated list combining excludedTools with
1356+
// the names of any SDK-registered tools, so the CLI won't handle them.
1357+
func mergeExcludedTools(excludedTools []string, tools []Tool) []string {
1358+
if len(tools) == 0 {
1359+
return excludedTools
1360+
}
1361+
seen := make(map[string]bool, len(excludedTools)+len(tools))
1362+
merged := make([]string, 0, len(excludedTools)+len(tools))
1363+
for _, name := range excludedTools {
1364+
if !seen[name] {
1365+
seen[name] = true
1366+
merged = append(merged, name)
1367+
}
1368+
}
1369+
for _, t := range tools {
1370+
if !seen[t.Name] {
1371+
seen[t.Name] = true
1372+
merged = append(merged, t.Name)
1373+
}
1374+
}
1375+
return merged
1376+
}
1377+
13551378
func buildUnsupportedToolResult(toolName string) ToolResult {
13561379
return ToolResult{
13571380
TextResultForLLM: fmt.Sprintf("Tool '%s' is not supported by this client instance.", toolName),

go/client_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,3 +444,39 @@ func TestResumeSessionRequest_ClientName(t *testing.T) {
444444
}
445445
})
446446
}
447+
448+
func TestMergeExcludedTools(t *testing.T) {
449+
t.Run("adds tool names to excluded tools", func(t *testing.T) {
450+
tools := []Tool{{Name: "edit_file"}, {Name: "read_file"}}
451+
got := mergeExcludedTools(nil, tools)
452+
want := []string{"edit_file", "read_file"}
453+
if !reflect.DeepEqual(got, want) {
454+
t.Errorf("got %v, want %v", got, want)
455+
}
456+
})
457+
458+
t.Run("deduplicates with existing excluded tools", func(t *testing.T) {
459+
excluded := []string{"edit_file", "run_shell"}
460+
tools := []Tool{{Name: "edit_file"}, {Name: "read_file"}}
461+
got := mergeExcludedTools(excluded, tools)
462+
want := []string{"edit_file", "run_shell", "read_file"}
463+
if !reflect.DeepEqual(got, want) {
464+
t.Errorf("got %v, want %v", got, want)
465+
}
466+
})
467+
468+
t.Run("returns original list when no tools provided", func(t *testing.T) {
469+
excluded := []string{"edit_file"}
470+
got := mergeExcludedTools(excluded, nil)
471+
if !reflect.DeepEqual(got, excluded) {
472+
t.Errorf("got %v, want %v", got, excluded)
473+
}
474+
})
475+
476+
t.Run("returns nil when both inputs are empty", func(t *testing.T) {
477+
got := mergeExcludedTools(nil, nil)
478+
if got != nil {
479+
t.Errorf("got %v, want nil", got)
480+
}
481+
})
482+
}

nodejs/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,18 @@ const session = await client.createSession({
402402

403403
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.
404404

405+
#### Overriding Built-in Tools
406+
407+
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.
408+
409+
```ts
410+
defineTool("edit_file", {
411+
description: "Custom file editor with project-specific validation",
412+
parameters: z.object({ path: z.string(), content: z.string() }),
413+
handler: async ({ path, content }) => { /* your logic */ },
414+
})
415+
```
416+
405417
### System Message Customization
406418

407419
Control the system prompt using `systemMessage` in session config:

nodejs/src/client.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,19 @@ import type {
5050
TypedSessionLifecycleHandler,
5151
} from "./types.js";
5252

53+
/**
54+
* Merge user-provided excludedTools with tool names from config.tools so that
55+
* SDK-registered tools automatically override built-in CLI tools.
56+
*/
57+
function mergeExcludedTools(
58+
excludedTools: string[] | undefined,
59+
tools: Tool[] | undefined
60+
): string[] | undefined {
61+
const toolNames = tools?.map((t) => t.name);
62+
if (!excludedTools?.length && !toolNames?.length) return excludedTools;
63+
return [...new Set([...(excludedTools ?? []), ...(toolNames ?? [])])];
64+
}
65+
5366
/**
5467
* Check if value is a Zod schema (has toJSONSchema method)
5568
*/
@@ -529,7 +542,7 @@ export class CopilotClient {
529542
})),
530543
systemMessage: config.systemMessage,
531544
availableTools: config.availableTools,
532-
excludedTools: config.excludedTools,
545+
excludedTools: mergeExcludedTools(config.excludedTools, config.tools),
533546
provider: config.provider,
534547
requestPermission: true,
535548
requestUserInput: !!config.onUserInputRequest,
@@ -607,7 +620,7 @@ export class CopilotClient {
607620
reasoningEffort: config.reasoningEffort,
608621
systemMessage: config.systemMessage,
609622
availableTools: config.availableTools,
610-
excludedTools: config.excludedTools,
623+
excludedTools: mergeExcludedTools(config.excludedTools, config.tools),
611624
tools: config.tools?.map((tool) => ({
612625
name: tool.name,
613626
description: tool.description,

nodejs/test/client.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,4 +243,71 @@ describe("CopilotClient", () => {
243243
}).toThrow(/githubToken and useLoggedInUser cannot be used with cliUrl/);
244244
});
245245
});
246+
247+
describe("excludedTools merging with config.tools", () => {
248+
it("adds tool names from config.tools to excludedTools in session.create", async () => {
249+
const client = new CopilotClient();
250+
await client.start();
251+
onTestFinished(() => client.forceStop());
252+
253+
const spy = vi.spyOn((client as any).connection!, "sendRequest");
254+
await client.createSession({
255+
tools: [{ name: "edit_file", description: "edit", handler: async () => "ok" }],
256+
});
257+
258+
expect(spy).toHaveBeenCalledWith(
259+
"session.create",
260+
expect.objectContaining({ excludedTools: ["edit_file"] })
261+
);
262+
});
263+
264+
it("merges and deduplicates with existing excludedTools", async () => {
265+
const client = new CopilotClient();
266+
await client.start();
267+
onTestFinished(() => client.forceStop());
268+
269+
const spy = vi.spyOn((client as any).connection!, "sendRequest");
270+
await client.createSession({
271+
tools: [{ name: "edit_file", description: "edit", handler: async () => "ok" }],
272+
excludedTools: ["edit_file", "run_command"],
273+
});
274+
275+
const payload = spy.mock.calls.find((c) => c[0] === "session.create")![1] as any;
276+
expect(payload.excludedTools).toEqual(
277+
expect.arrayContaining(["edit_file", "run_command"])
278+
);
279+
expect(payload.excludedTools).toHaveLength(2);
280+
});
281+
282+
it("leaves excludedTools unchanged when no tools provided", async () => {
283+
const client = new CopilotClient();
284+
await client.start();
285+
onTestFinished(() => client.forceStop());
286+
287+
const spy = vi.spyOn((client as any).connection!, "sendRequest");
288+
await client.createSession({ excludedTools: ["run_command"] });
289+
290+
expect(spy).toHaveBeenCalledWith(
291+
"session.create",
292+
expect.objectContaining({ excludedTools: ["run_command"] })
293+
);
294+
});
295+
296+
it("adds tool names from config.tools to excludedTools in session.resume", async () => {
297+
const client = new CopilotClient();
298+
await client.start();
299+
onTestFinished(() => client.forceStop());
300+
301+
const session = await client.createSession();
302+
const spy = vi.spyOn((client as any).connection!, "sendRequest");
303+
await client.resumeSession(session.sessionId, {
304+
tools: [{ name: "edit_file", description: "edit", handler: async () => "ok" }],
305+
});
306+
307+
expect(spy).toHaveBeenCalledWith(
308+
"session.resume",
309+
expect.objectContaining({ excludedTools: ["edit_file"] })
310+
);
311+
});
312+
});
246313
});

0 commit comments

Comments
 (0)