Skip to content

Commit 94e9daa

Browse files
Copilotpatniko
authored 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 5c977f1 commit 94e9daa

13 files changed

Lines changed: 397 additions & 11 deletions

File tree

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
@@ -389,7 +389,7 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
389389
config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(),
390390
config.SystemMessage,
391391
config.AvailableTools,
392-
config.ExcludedTools,
392+
MergeExcludedTools(config.ExcludedTools, config.Tools),
393393
config.Provider,
394394
(bool?)true,
395395
config.OnUserInputRequest != null ? true : null,
@@ -480,7 +480,7 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
480480
config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(),
481481
config.SystemMessage,
482482
config.AvailableTools,
483-
config.ExcludedTools,
483+
MergeExcludedTools(config.ExcludedTools, config.Tools),
484484
config.Provider,
485485
(bool?)true,
486486
config.OnUserInputRequest != null ? true : null,
@@ -862,6 +862,14 @@ private void DispatchLifecycleEvent(SessionLifecycleEvent evt)
862862
}
863863
}
864864

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+
865873
internal static async Task<T> InvokeRpcAsync<T>(JsonRpc rpc, string method, object?[]? args, CancellationToken cancellationToken)
866874
{
867875
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
@@ -267,6 +267,17 @@ session, _ := client.CreateSession(context.Background(), &copilot.SessionConfig{
267267

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

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

272283
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
@@ -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 = config.ExcludedTools
494+
req.ExcludedTools = mergeExcludedTools(config.ExcludedTools, config.Tools)
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 = config.ExcludedTools
591+
req.ExcludedTools = mergeExcludedTools(config.ExcludedTools, config.Tools)
592592
if config.Streaming {
593593
req.Streaming = Bool(true)
594594
}
@@ -1411,6 +1411,29 @@ func buildFailedToolResult(internalError string) ToolResult {
14111411
}
14121412

14131413
// buildUnsupportedToolResult creates a failure ToolResult for an unsupported tool.
1414+
// mergeExcludedTools returns a deduplicated list combining excludedTools with
1415+
// the names of any SDK-registered tools, so the CLI won't handle them.
1416+
func mergeExcludedTools(excludedTools []string, tools []Tool) []string {
1417+
if len(tools) == 0 {
1418+
return excludedTools
1419+
}
1420+
seen := make(map[string]bool, len(excludedTools)+len(tools))
1421+
merged := make([]string, 0, len(excludedTools)+len(tools))
1422+
for _, name := range excludedTools {
1423+
if !seen[name] {
1424+
seen[name] = true
1425+
merged = append(merged, name)
1426+
}
1427+
}
1428+
for _, t := range tools {
1429+
if !seen[t.Name] {
1430+
seen[t.Name] = true
1431+
merged = append(merged, t.Name)
1432+
}
1433+
}
1434+
return merged
1435+
}
1436+
14141437
func buildUnsupportedToolResult(toolName string) ToolResult {
14151438
return ToolResult{
14161439
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
@@ -528,3 +528,39 @@ func TestClient_StartStopRace(t *testing.T) {
528528
t.Fatal(err)
529529
}
530530
}
531+
532+
func TestMergeExcludedTools(t *testing.T) {
533+
t.Run("adds tool names to excluded tools", func(t *testing.T) {
534+
tools := []Tool{{Name: "edit_file"}, {Name: "read_file"}}
535+
got := mergeExcludedTools(nil, tools)
536+
want := []string{"edit_file", "read_file"}
537+
if !reflect.DeepEqual(got, want) {
538+
t.Errorf("got %v, want %v", got, want)
539+
}
540+
})
541+
542+
t.Run("deduplicates with existing excluded tools", func(t *testing.T) {
543+
excluded := []string{"edit_file", "run_shell"}
544+
tools := []Tool{{Name: "edit_file"}, {Name: "read_file"}}
545+
got := mergeExcludedTools(excluded, tools)
546+
want := []string{"edit_file", "run_shell", "read_file"}
547+
if !reflect.DeepEqual(got, want) {
548+
t.Errorf("got %v, want %v", got, want)
549+
}
550+
})
551+
552+
t.Run("returns original list when no tools provided", func(t *testing.T) {
553+
excluded := []string{"edit_file"}
554+
got := mergeExcludedTools(excluded, nil)
555+
if !reflect.DeepEqual(got, excluded) {
556+
t.Errorf("got %v, want %v", got, excluded)
557+
}
558+
})
559+
560+
t.Run("returns nil when both inputs are empty", func(t *testing.T) {
561+
got := mergeExcludedTools(nil, nil)
562+
if got != nil {
563+
t.Errorf("got %v, want nil", got)
564+
}
565+
})
566+
}

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
*/
@@ -536,7 +549,7 @@ export class CopilotClient {
536549
})),
537550
systemMessage: config.systemMessage,
538551
availableTools: config.availableTools,
539-
excludedTools: config.excludedTools,
552+
excludedTools: mergeExcludedTools(config.excludedTools, config.tools),
540553
provider: config.provider,
541554
requestPermission: true,
542555
requestUserInput: !!config.onUserInputRequest,
@@ -616,7 +629,7 @@ export class CopilotClient {
616629
reasoningEffort: config.reasoningEffort,
617630
systemMessage: config.systemMessage,
618631
availableTools: config.availableTools,
619-
excludedTools: config.excludedTools,
632+
excludedTools: mergeExcludedTools(config.excludedTools, config.tools),
620633
tools: config.tools?.map((tool) => ({
621634
name: tool.name,
622635
description: tool.description,

nodejs/test/client.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,4 +293,71 @@ describe("CopilotClient", () => {
293293
}).toThrow(/githubToken and useLoggedInUser cannot be used with cliUrl/);
294294
});
295295
});
296+
297+
describe("excludedTools merging with config.tools", () => {
298+
it("adds tool names from config.tools to excludedTools in session.create", async () => {
299+
const client = new CopilotClient();
300+
await client.start();
301+
onTestFinished(() => client.forceStop());
302+
303+
const spy = vi.spyOn((client as any).connection!, "sendRequest");
304+
await client.createSession({
305+
tools: [{ name: "edit_file", description: "edit", handler: async () => "ok" }],
306+
});
307+
308+
expect(spy).toHaveBeenCalledWith(
309+
"session.create",
310+
expect.objectContaining({ excludedTools: ["edit_file"] })
311+
);
312+
});
313+
314+
it("merges and deduplicates with existing excludedTools", async () => {
315+
const client = new CopilotClient();
316+
await client.start();
317+
onTestFinished(() => client.forceStop());
318+
319+
const spy = vi.spyOn((client as any).connection!, "sendRequest");
320+
await client.createSession({
321+
tools: [{ name: "edit_file", description: "edit", handler: async () => "ok" }],
322+
excludedTools: ["edit_file", "run_command"],
323+
});
324+
325+
const payload = spy.mock.calls.find((c) => c[0] === "session.create")![1] as any;
326+
expect(payload.excludedTools).toEqual(
327+
expect.arrayContaining(["edit_file", "run_command"])
328+
);
329+
expect(payload.excludedTools).toHaveLength(2);
330+
});
331+
332+
it("leaves excludedTools unchanged when no tools provided", async () => {
333+
const client = new CopilotClient();
334+
await client.start();
335+
onTestFinished(() => client.forceStop());
336+
337+
const spy = vi.spyOn((client as any).connection!, "sendRequest");
338+
await client.createSession({ excludedTools: ["run_command"] });
339+
340+
expect(spy).toHaveBeenCalledWith(
341+
"session.create",
342+
expect.objectContaining({ excludedTools: ["run_command"] })
343+
);
344+
});
345+
346+
it("adds tool names from config.tools to excludedTools in session.resume", async () => {
347+
const client = new CopilotClient();
348+
await client.start();
349+
onTestFinished(() => client.forceStop());
350+
351+
const session = await client.createSession();
352+
const spy = vi.spyOn((client as any).connection!, "sendRequest");
353+
await client.resumeSession(session.sessionId, {
354+
tools: [{ name: "edit_file", description: "edit", handler: async () => "ok" }],
355+
});
356+
357+
expect(spy).toHaveBeenCalledWith(
358+
"session.resume",
359+
expect.objectContaining({ excludedTools: ["edit_file"] })
360+
);
361+
});
362+
});
296363
});

0 commit comments

Comments
 (0)