-
Notifications
You must be signed in to change notification settings - Fork 877
feat: Support overriding built-in tools #522
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<AIFunction> | ||
| { | ||
| 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<string> { "view", "my_tool" }; | ||
| var tools = new List<AIFunction> | ||
| { | ||
| 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<string> { "view" }; | ||
| var result = CopilotClient.MergeExcludedTools(existing, new List<AIFunction>()); | ||
|
|
||
| Assert.Same(existing, result); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void Returns_Tool_Names_When_ExcludedTools_Null() | ||
| { | ||
| var tools = new List<AIFunction> | ||
| { | ||
| 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() => ""; | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -266,6 +266,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 | ||||||||||||
|
Comment on lines
+275
to
+276
|
||||||||||||
| func(params EditFileParams, inv copilot.ToolInvocation) (any, error) { | |
| // your logic | |
| func(path string, content string, inv copilot.ToolInvocation) (any, error) { | |
| // your logic | |
| return "File edited", nil |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -461,7 +461,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 | ||
|
|
@@ -558,7 +558,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) | ||
| } | ||
|
|
@@ -1352,6 +1352,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. | ||
|
Comment on lines
1354
to
+1356
|
||
| 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), | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 */ }, | ||||||||||||
|
||||||||||||
| handler: async ({ path, content }) => { /* your logic */ }, | |
| handler: async ({ path, content }) => { | |
| // your logic here, e.g. validate and write file contents | |
| return "File edited successfully"; | |
| }, |
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||
|
||||||||
| # your logic | |
| # Example: perform custom validation and editing logic here | |
| return f"Edited {params.path}" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The documentation example shows an incomplete function handler with only a comment placeholder. While concise, it would be more helpful to show a minimal but complete example that returns a value, such as
return "File edited successfully";to demonstrate the expected return type and give developers a working starting point.