Skip to content
Merged
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
24 changes: 24 additions & 0 deletions dotnet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,30 @@ 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`), the runtime will return an error unless you explicitly opt in by setting `is_override` in the tool's `AdditionalProperties`. This flag signals that you intend to replace the built-in tool with your custom implementation.

```csharp
var editFile = AIFunctionFactory.Create(
async ([Description("File path")] string path, [Description("New content")] string content) => {
// your logic
},
"edit_file",
"Custom file editor with project-specific validation",
new AIFunctionFactoryOptions
{
AdditionalProperties = new ReadOnlyDictionary<string, object?>(
new Dictionary<string, object?> { ["is_override"] = true })
});

var session = await client.CreateSessionAsync(new SessionConfig
{
Model = "gpt-5",
Tools = [editFile],
});
```

### System Message Customization

Control the system prompt using `SystemMessage` in session config:
Expand Down
9 changes: 7 additions & 2 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1416,10 +1416,15 @@ internal record CreateSessionRequest(
internal record ToolDefinition(
string Name,
string? Description,
JsonElement Parameters /* JSON schema */)
JsonElement Parameters, /* JSON schema */
bool? OverridesBuiltInTool = null)
{
public static ToolDefinition FromAIFunction(AIFunction function)
=> new ToolDefinition(function.Name, function.Description, function.JsonSchema);
{
var overrides = function.AdditionalProperties.TryGetValue("is_override", out var val) && val is true;
return new ToolDefinition(function.Name, function.Description, function.JsonSchema,
overrides ? true : null);
}
}

internal record CreateSessionResponse(
Expand Down
1 change: 1 addition & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,7 @@ protected SessionConfig(SessionConfig? other)
public string? ConfigDir { get; set; }

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

public SystemMessageConfig? SystemMessage { get; set; }
public List<string>? AvailableTools { get; set; }
public List<string>? ExcludedTools { get; set; }
Expand Down
29 changes: 29 additions & 0 deletions dotnet/test/ToolsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

using GitHub.Copilot.SDK.Test.Harness;
using Microsoft.Extensions.AI;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Text.Json;
Expand Down Expand Up @@ -152,6 +153,34 @@ record City(int CountryId, string CityName, int Population);
[JsonSerializable(typeof(JsonElement))]
private partial class ToolsTestsJsonContext : JsonSerializerContext;

[Fact]
public async Task Overrides_Built_In_Tool_With_Custom_Tool()
{
var session = await CreateSessionAsync(new SessionConfig
{
Tools = [AIFunctionFactory.Create((Delegate)CustomGrep, new AIFunctionFactoryOptions
{
Name = "grep",
AdditionalProperties = new ReadOnlyDictionary<string, object?>(
new Dictionary<string, object?> { ["is_override"] = true })
})],
OnPermissionRequest = PermissionHandler.ApproveAll,
});

await session.SendAsync(new MessageOptions
{
Prompt = "Use grep to search for the word 'hello'"
});

var assistantMessage = await TestHelper.GetFinalAssistantMessageAsync(session);
Assert.NotNull(assistantMessage);
Assert.Contains("CUSTOM_GREP_RESULT", assistantMessage!.Data.Content ?? string.Empty);

[Description("A custom grep implementation that overrides the built-in")]
static string CustomGrep([Description("Search query")] string query)
=> $"CUSTOM_GREP_RESULT: {query}";
}

[Fact(Skip = "Behaves as if no content was in the result. Likely that binary results aren't fully implemented yet.")]
public async Task Can_Return_Binary_Result()
{
Expand Down
12 changes: 12 additions & 0 deletions go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,18 @@ 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`), 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.

```go
editFile := copilot.DefineTool("edit_file", "Custom file editor with project-specific validation",
func(params EditFileParams, inv copilot.ToolInvocation) (any, error) {
// your logic
})
editFile.OverridesBuiltInTool = true
```

## Streaming

Enable streaming to receive assistant response chunks as they're generated:
Expand Down
41 changes: 41 additions & 0 deletions go/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,47 @@ func TestResumeSessionRequest_ClientName(t *testing.T) {
})
}

func TestOverridesBuiltInTool(t *testing.T) {
t.Run("OverridesBuiltInTool is serialized in tool definition", func(t *testing.T) {
tool := Tool{
Name: "grep",
Description: "Custom grep",
OverridesBuiltInTool: true,
Handler: func(_ ToolInvocation) (ToolResult, error) { return ToolResult{}, nil },
}
data, err := json.Marshal(tool)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if v, ok := m["overridesBuiltInTool"]; !ok || v != true {
t.Errorf("expected overridesBuiltInTool=true, got %v", m)
}
})

t.Run("OverridesBuiltInTool omitted when false", func(t *testing.T) {
tool := Tool{
Name: "custom_tool",
Description: "A custom tool",
Handler: func(_ ToolInvocation) (ToolResult, error) { return ToolResult{}, nil },
}
data, err := json.Marshal(tool)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if _, ok := m["overridesBuiltInTool"]; ok {
t.Errorf("expected overridesBuiltInTool to be omitted, got %v", m)
}
})
}

func TestClient_CreateSession_RequiresPermissionHandler(t *testing.T) {
t.Run("returns error when config is nil", func(t *testing.T) {
client := NewClient(nil)
Expand Down
38 changes: 38 additions & 0 deletions go/internal/e2e/tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,44 @@ func TestTools(t *testing.T) {
}
})

t.Run("overrides built-in tool with custom tool", func(t *testing.T) {
ctx.ConfigureForTest(t)

type GrepParams struct {
Query string `json:"query" jsonschema:"Search query"`
}

grepTool := copilot.DefineTool("grep", "A custom grep implementation that overrides the built-in",
func(params GrepParams, inv copilot.ToolInvocation) (string, error) {
return "CUSTOM_GREP_RESULT: " + params.Query, nil
})
grepTool.OverridesBuiltInTool = true

session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{
OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
Tools: []copilot.Tool{
grepTool,
},
})
if err != nil {
t.Fatalf("Failed to create session: %v", err)
}

_, err = session.Send(t.Context(), copilot.MessageOptions{Prompt: "Use grep to search for the word 'hello'"})
if err != nil {
t.Fatalf("Failed to send message: %v", err)
}

answer, err := testharness.GetFinalAssistantMessage(t.Context(), session)
if err != nil {
t.Fatalf("Failed to get assistant message: %v", err)
}

if answer.Data.Content == nil || !strings.Contains(*answer.Data.Content, "CUSTOM_GREP_RESULT") {
t.Errorf("Expected answer to contain 'CUSTOM_GREP_RESULT', got %v", answer.Data.Content)
}
})

t.Run("invokes custom tool with permission handler", func(t *testing.T) {
ctx.ConfigureForTest(t)

Expand Down
9 changes: 5 additions & 4 deletions go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -410,10 +410,11 @@ type SessionConfig struct {

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

// ToolInvocation describes a tool call initiated by Copilot
Expand Down
13 changes: 13 additions & 0 deletions nodejs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,19 @@ 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`), 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.

```ts
defineTool("edit_file", {
description: "Custom file editor with project-specific validation",
parameters: z.object({ path: z.string(), content: z.string() }),
overridesBuiltInTool: true,
handler: async ({ path, content }) => { /* your logic */ },
})
```

### System Message Customization

Control the system prompt using `systemMessage` in session config:
Expand Down
2 changes: 2 additions & 0 deletions nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,7 @@ export class CopilotClient {
name: tool.name,
description: tool.description,
parameters: toJsonSchema(tool.parameters),
overridesBuiltInTool: tool.overridesBuiltInTool,
})),
systemMessage: config.systemMessage,
availableTools: config.availableTools,
Expand Down Expand Up @@ -621,6 +622,7 @@ export class CopilotClient {
name: tool.name,
description: tool.description,
parameters: toJsonSchema(tool.parameters),
overridesBuiltInTool: tool.overridesBuiltInTool,
})),
provider: config.provider,
requestPermission: true,
Expand Down
7 changes: 7 additions & 0 deletions nodejs/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,12 @@ export interface Tool<TArgs = unknown> {
description?: string;
parameters?: ZodSchema<TArgs> | Record<string, unknown>;
handler: ToolHandler<TArgs>;
/**
* When true, explicitly indicates this tool is intended to override a built-in tool
* of the same name. If not set and the name clashes with a built-in tool, the runtime
* will return an error.
*/
overridesBuiltInTool?: boolean;
}

/**
Expand All @@ -158,6 +164,7 @@ export function defineTool<T = unknown>(
description?: string;
parameters?: ZodSchema<T> | Record<string, unknown>;
handler: ToolHandler<T>;
overridesBuiltInTool?: boolean;
}
): Tool<T> {
return { name, ...config };
Expand Down
51 changes: 51 additions & 0 deletions nodejs/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,4 +293,55 @@ describe("CopilotClient", () => {
}).toThrow(/githubToken and useLoggedInUser cannot be used with cliUrl/);
});
});

describe("overridesBuiltInTool in tool definitions", () => {
it("sends overridesBuiltInTool in tool definition on 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({
onPermissionRequest: approveAll,
tools: [
{
name: "grep",
description: "custom grep",
handler: async () => "ok",
overridesBuiltInTool: true,
},
],
});

const payload = spy.mock.calls.find((c) => c[0] === "session.create")![1] as any;
expect(payload.tools).toEqual([
expect.objectContaining({ name: "grep", overridesBuiltInTool: true }),
]);
});

it("sends overridesBuiltInTool in tool definition on session.resume", async () => {
const client = new CopilotClient();
await client.start();
onTestFinished(() => client.forceStop());

const session = await client.createSession({ onPermissionRequest: approveAll });
const spy = vi.spyOn((client as any).connection!, "sendRequest");
await client.resumeSession(session.sessionId, {
onPermissionRequest: approveAll,
tools: [
{
name: "grep",
description: "custom grep",
handler: async () => "ok",
overridesBuiltInTool: true,
},
],
});

const payload = spy.mock.calls.find((c) => c[0] === "session.resume")![1] as any;
expect(payload.tools).toEqual([
expect.objectContaining({ name: "grep", overridesBuiltInTool: true }),
]);
});
});
});
21 changes: 21 additions & 0 deletions nodejs/test/e2e/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,27 @@ describe("Custom tools", async () => {
expect(customToolRequests[0].toolName).toBe("encrypt_string");
});

it("overrides built-in tool with custom tool", async () => {
const session = await client.createSession({
onPermissionRequest: approveAll,
tools: [
defineTool("grep", {
description: "A custom grep implementation that overrides the built-in",
parameters: z.object({
query: z.string().describe("Search query"),
}),
handler: ({ query }) => `CUSTOM_GREP_RESULT: ${query}`,
overridesBuiltInTool: true,
}),
],
});

const assistantMessage = await session.sendAndWait({
prompt: "Use grep to search for the word 'hello'",
});
expect(assistantMessage?.data.content).toContain("CUSTOM_GREP_RESULT");
});

it("denies custom tool when permission denied", async () => {
let toolHandlerCalled = false;

Expand Down
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`), the SDK will throw an error unless you explicitly opt in by setting `overrides_built_in_tool=True`. This flag signals that you intend to replace the built-in tool with your custom implementation.

```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", overrides_built_in_tool=True)
async def edit_file(params: EditFileParams) -> str:
# your logic
```

## Image Support

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