Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions dotnet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
```
Comment on lines +423 to +429
Copy link

Copilot AI Feb 20, 2026

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.

Copilot uses AI. Check for mistakes.

### System Message Customization

Control the system prompt using `SystemMessage` in session config:
Expand Down
12 changes: 10 additions & 2 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,7 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig? config = nul
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,
Expand Down Expand Up @@ -467,7 +467,7 @@ public async Task<CopilotSession> 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,
Expand Down Expand Up @@ -852,6 +852,14 @@ private void DispatchLifecycleEvent(SessionLifecycleEvent evt)
}
}

internal static List<string>? MergeExcludedTools(List<string>? excludedTools, ICollection<AIFunction>? 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<T> InvokeRpcAsync<T>(JsonRpc rpc, string method, object?[]? args, CancellationToken cancellationToken)
{
return await InvokeRpcAsync<T>(rpc, method, args, null, cancellationToken);
Expand Down
4 changes: 4 additions & 0 deletions dotnet/src/GitHub.Copilot.SDK.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>

<ItemGroup>
<InternalsVisibleTo Include="GitHub.Copilot.SDK.Test" />
</ItemGroup>

<ItemGroup>
<None Include="../README.md" Pack="true" PackagePath="/" />
</ItemGroup>
Expand Down
79 changes: 79 additions & 0 deletions dotnet/test/MergeExcludedToolsTests.cs
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() => "";
}
11 changes: 11 additions & 0 deletions go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation example references EditFileParams which is not defined anywhere in the example. Either define this type in the example or use a simpler inline parameter list. For example: func(path string, content string, inv copilot.ToolInvocation) (any, error) { return "File edited", nil }

Suggested change
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

Copilot uses AI. Check for mistakes.
})
```

## Streaming

Enable streaming to receive assistant response chunks as they're generated:
Expand Down
27 changes: 25 additions & 2 deletions go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment for mergeExcludedTools (lines 1355-1356) is positioned between the comment for buildUnsupportedToolResult (line 1354) and the function definition (line 1357). This creates confusion about which function the comment belongs to. Move the comment for mergeExcludedTools to immediately precede the function definition on line 1357.

Copilot uses AI. Check for mistakes.
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),
Expand Down
36 changes: 36 additions & 0 deletions go/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -444,3 +444,39 @@ func TestResumeSessionRequest_ClientName(t *testing.T) {
}
})
}

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)
}
})
}
12 changes: 12 additions & 0 deletions nodejs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 */ },
Copy link

Copilot AI Feb 20, 2026

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.

Suggested change
handler: async ({ path, content }) => { /* your logic */ },
handler: async ({ path, content }) => {
// your logic here, e.g. validate and write file contents
return "File edited successfully";
},

Copilot uses AI. Check for mistakes.
})
```

### System Message Customization

Control the system prompt using `systemMessage` in session config:
Expand Down
17 changes: 15 additions & 2 deletions nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*/
Expand Down Expand Up @@ -529,7 +542,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,
Expand Down Expand Up @@ -607,7 +620,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,
Expand Down
67 changes: 67 additions & 0 deletions nodejs/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,4 +243,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"] })
);
});
});
});
14 changes: 14 additions & 0 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Feb 20, 2026

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 f"Edited {path}" to demonstrate the expected return type and give developers a working starting point.

Suggested change
# your logic
# Example: perform custom validation and editing logic here
return f"Edited {params.path}"

Copilot uses AI. Check for mistakes.
```

## Image Support

The SDK supports image attachments via the `attachments` parameter. You can attach images by providing their file path:
Expand Down
Loading
Loading