From fd4822835c463577943425801388c9668b9146bf Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 21:56:03 -0400 Subject: [PATCH 1/2] Add .NET CopilotTool helper Add a CopilotTool.DefineTool helper that wraps Microsoft.Extensions.AI tool creation with Copilot-specific metadata and ToolInvocation binding support. Update .NET documentation and tool override examples to use typed CopilotToolOptions instead of raw metadata keys. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 2 +- docs/getting-started.md | 20 ++- .../integrations/microsoft-agent-framework.md | 9 +- dotnet/README.md | 51 ++++--- dotnet/src/Client.cs | 4 +- dotnet/src/CopilotTool.cs | 137 +++++++++++++++++ dotnet/test/Unit/CopilotToolTests.cs | 138 ++++++++++++++++++ test/scenarios/tools/tool-overrides/README.md | 2 +- .../tools/tool-overrides/csharp/Program.cs | 9 +- 9 files changed, 334 insertions(+), 38 deletions(-) create mode 100644 dotnet/src/CopilotTool.cs create mode 100644 dotnet/test/Unit/CopilotToolTests.cs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 098e1a70c..1dad5f95c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -35,7 +35,7 @@ ## Project-specific conventions & patterns ✅ -- Tools: each SDK has helper APIs to expose functions as tools; prefer the language's `DefineTool`/`@define_tool`/`AIFunctionFactory.Create` patterns (see language READMEs). +- Tools: each SDK has helper APIs to expose functions as tools; prefer the language's `DefineTool`/`@define_tool`/`CopilotTool.DefineTool` patterns (see language READMEs). - Infinite sessions are enabled by default and persist workspace state to `~/.copilot/session-state/{sessionId}`; compaction events are emitted (`session.compaction_start`, `session.compaction_complete`). See language READMEs for usage. - Streaming: when `streaming`/`Streaming=true` you receive delta events (`assistant.message_delta`, `assistant.reasoning_delta`) and final events (`assistant.message`, `assistant.reasoning`) — tests expect this behavior. - Type generation is centralized in `nodejs/scripts/generate-session-types.ts` and requires the `@github/copilot` schema to be present (often via `npm link` or installed package). diff --git a/docs/getting-started.md b/docs/getting-started.md index 4ee6bd298..8d81fe48d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1166,7 +1166,7 @@ using System.ComponentModel; await using var client = new CopilotClient(); // Define a tool that Copilot can call -var getWeather = AIFunctionFactory.Create( +var getWeather = CopilotTool.DefineTool( ([Description("The city name")] string city) => { // In a real app, you'd call a weather API here @@ -1175,8 +1175,11 @@ var getWeather = AIFunctionFactory.Create( var condition = conditions[Random.Shared.Next(conditions.Length)]; return new { city, temperature = $"{temp}°F", condition }; }, - "get_weather", - "Get the current weather for a city" + factoryOptions: new AIFunctionFactoryOptions + { + Name = "get_weather", + Description = "Get the current weather for a city", + } ); await using var session = await client.CreateSessionAsync(new SessionConfig @@ -1648,8 +1651,8 @@ using GitHub.Copilot.SDK; using Microsoft.Extensions.AI; using System.ComponentModel; -// Define the weather tool using AIFunctionFactory -var getWeather = AIFunctionFactory.Create( +// Define the weather tool +var getWeather = CopilotTool.DefineTool( ([Description("The city name")] string city) => { var conditions = new[] { "sunny", "cloudy", "rainy", "partly cloudy" }; @@ -1657,8 +1660,11 @@ var getWeather = AIFunctionFactory.Create( var condition = conditions[Random.Shared.Next(conditions.Length)]; return new { city, temperature = $"{temp}°F", condition }; }, - "get_weather", - "Get the current weather for a city"); + factoryOptions: new AIFunctionFactoryOptions + { + Name = "get_weather", + Description = "Get the current weather for a city", + }); await using var client = new CopilotClient(); await using var session = await client.CreateSessionAsync(new SessionConfig diff --git a/docs/integrations/microsoft-agent-framework.md b/docs/integrations/microsoft-agent-framework.md index 2f9f1966a..8d75d0038 100644 --- a/docs/integrations/microsoft-agent-framework.md +++ b/docs/integrations/microsoft-agent-framework.md @@ -151,10 +151,13 @@ using Microsoft.Extensions.AI; using Microsoft.Agents.AI; // Define a custom tool -AIFunction weatherTool = AIFunctionFactory.Create( +AIFunction weatherTool = CopilotTool.DefineTool( (string location) => $"The weather in {location} is sunny with a high of 25°C.", - "GetWeather", - "Get the current weather for a given location." + factoryOptions: new AIFunctionFactoryOptions + { + Name = "GetWeather", + Description = "Get the current weather for a given location.", + } ); await using var copilotClient = new CopilotClient(); diff --git a/dotnet/README.md b/dotnet/README.md index 6b76f3913..8e078c36f 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -425,7 +425,7 @@ await client.StopAsync(); ### Tools -You can let the CLI call back into your process when the model needs capabilities you own. Use `AIFunctionFactory.Create` from Microsoft.Extensions.AI for type-safe tool definitions: +You can let the CLI call back into your process when the model needs capabilities you own. Use `CopilotTool.DefineTool` for type-safe tool definitions: ```csharp using Microsoft.Extensions.AI; @@ -435,34 +435,39 @@ var session = await client.CreateSessionAsync(new SessionConfig { Model = "gpt-5", Tools = [ - AIFunctionFactory.Create( + CopilotTool.DefineTool( async ([Description("Issue identifier")] string id) => { var issue = await FetchIssueAsync(id); return issue; }, - "lookup_issue", - "Fetch issue details from our tracker"), + factoryOptions: new AIFunctionFactoryOptions + { + Name = "lookup_issue", + Description = "Fetch issue details from our tracker", + }), ] }); ``` -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. +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. Include a `ToolInvocation` parameter in your handler if you need the session ID, tool call ID, tool name, or raw arguments. #### 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. +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 with `CopilotToolOptions.OverridesBuiltInTool`. This flag signals that you intend to replace the built-in tool with your custom implementation. ```csharp -var editFile = AIFunctionFactory.Create( +var editFile = CopilotTool.DefineTool( async ([Description("File path")] string path, [Description("New content")] string content) => { // your logic }, - "edit_file", - "Custom file editor with project-specific validation", - new AIFunctionFactoryOptions + toolOptions: new CopilotToolOptions + { + OverridesBuiltInTool = true + }, + factoryOptions: new AIFunctionFactoryOptions { - AdditionalProperties = new ReadOnlyDictionary( - new Dictionary { ["is_override"] = true }) + Name = "edit_file", + Description = "Custom file editor with project-specific validation", }); var session = await client.CreateSessionAsync(new SessionConfig @@ -474,22 +479,28 @@ var session = await client.CreateSessionAsync(new SessionConfig #### Skipping Permission Prompts -Set `skip_permission` in the tool's `AdditionalProperties` to allow it to execute without triggering a permission prompt: +Set `CopilotToolOptions.SkipPermission` to allow a tool to execute without triggering a permission prompt: ```csharp -var safeLookup = AIFunctionFactory.Create( +var safeLookup = CopilotTool.DefineTool( async ([Description("Lookup ID")] string id) => { // your logic }, - "safe_lookup", - "A read-only lookup that needs no confirmation", - new AIFunctionFactoryOptions + toolOptions: new CopilotToolOptions { - AdditionalProperties = new ReadOnlyDictionary( - new Dictionary { ["skip_permission"] = true }) + SkipPermission = true + }, + factoryOptions: new AIFunctionFactoryOptions + { + Name = "safe_lookup", + Description = "A read-only lookup that needs no confirmation", }); ``` +`DefineTool` delegates to `AIFunctionFactory.Create`, so advanced `AIFunctionFactoryOptions` remain available through the overload that accepts both `AIFunctionFactoryOptions` and `CopilotToolOptions`. + +If you want to use `AIFunctionFactory.Create` directly, you can set `skip_permission` in the tool's `AdditionalProperties`. + ## Commands Register slash commands so that users of the CLI's TUI can invoke custom actions via `/commandName`. Each command has a `Name`, optional `Description`, and a `Handler` called when the user executes it. @@ -789,7 +800,7 @@ var session = await client.ResumeSessionAsync("session-id", new ResumeSessionCon ### Per-Tool Skip Permission -To let a specific custom tool bypass the permission prompt entirely, set `skip_permission = true` in the tool's `AdditionalProperties`. See [Skipping Permission Prompts](#skipping-permission-prompts) under Tools. +To let a specific custom tool bypass the permission prompt entirely, set `SkipPermission = true` in `CopilotToolOptions`. See [Skipping Permission Prompts](#skipping-permission-prompts) under Tools. ## User Input Requests diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index b1e9dce0e..8df486bac 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1995,8 +1995,8 @@ internal record ToolDefinition( { public static ToolDefinition FromAIFunction(AIFunction function) { - var overrides = function.AdditionalProperties.TryGetValue("is_override", out var val) && val is true; - var skipPerm = function.AdditionalProperties.TryGetValue("skip_permission", out var skipVal) && skipVal is true; + var overrides = function.AdditionalProperties.TryGetValue(CopilotTool.OverridesBuiltInToolKey, out var val) && val is true; + var skipPerm = function.AdditionalProperties.TryGetValue(CopilotTool.SkipPermissionKey, out var skipVal) && skipVal is true; return new ToolDefinition(function.Name, function.Description, function.JsonSchema, overrides ? true : null, skipPerm ? true : null); diff --git a/dotnet/src/CopilotTool.cs b/dotnet/src/CopilotTool.cs new file mode 100644 index 000000000..ab2ea7396 --- /dev/null +++ b/dotnet/src/CopilotTool.cs @@ -0,0 +1,137 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using Microsoft.Extensions.AI; + +namespace GitHub.Copilot.SDK; + +/// +/// Provides helpers for defining Copilot tools. +/// +public static class CopilotTool +{ + /// The key used in to indicate that a tool intentionally overrides a built-in Copilot tool with the same name. + internal const string OverridesBuiltInToolKey = "is_override"; + + /// The key used in to indicate that a tool can execute without a permission prompt. + internal const string SkipPermissionKey = "skip_permission"; + + /// + /// Defines a tool for use in a . + /// + /// The delegate to invoke when the tool is called. + /// The Microsoft.Extensions.AI options used to create the function. + /// Copilot-specific tool options. + /// An that can be added to or . + /// + /// This is a helper on top of that applies additional configuration to support + /// Copilot tools, such as binding a parameter and adding Copilot-specific metadata properties based on the provided + /// . Any may be used as a Copilot tool; this helper simply provides additional conveniences + /// when tools that opt-in to using advanced features. + /// + public static AIFunction DefineTool( + Delegate method, + CopilotToolOptions? toolOptions = null, + AIFunctionFactoryOptions? factoryOptions = null) + { + ArgumentNullException.ThrowIfNull(method); + + factoryOptions ??= new(); + + ApplyToolOptions(factoryOptions, toolOptions); + ApplyToolInvocationBinding(factoryOptions); + + return AIFunctionFactory.Create(method, factoryOptions); + + static void ApplyToolInvocationBinding(AIFunctionFactoryOptions factoryOptions) + { + var configureParameterBinding = factoryOptions.ConfigureParameterBinding; + factoryOptions.ConfigureParameterBinding = pi => + { + var bindingOptions = configureParameterBinding?.Invoke(pi) ?? default; + + if (bindingOptions.BindParameter is null && + !bindingOptions.ExcludeFromSchema && + pi.ParameterType == typeof(ToolInvocation)) + { + return new AIFunctionFactoryOptions.ParameterBindingOptions + { + ExcludeFromSchema = true, + BindParameter = static (pi, arguments) => + { + // CopilotClient/CopilotSession attach this context object before invoking the AIFunction. + if (arguments.Context is not null && + arguments.Context.TryGetValue(typeof(ToolInvocation), out var invocation) && + invocation is ToolInvocation toolInvocation) + { + return toolInvocation; + } + + if (pi.HasDefaultValue) + { + return null; + } + + throw new InvalidOperationException($"No {nameof(ToolInvocation)} was provided for the tool call."); + } + }; + } + + return bindingOptions; + }; + } + + static void ApplyToolOptions(AIFunctionFactoryOptions factoryOptions, CopilotToolOptions? toolOptions) + { + if (toolOptions is not null && (toolOptions.OverridesBuiltInTool || toolOptions.SkipPermission)) + { + Dictionary additionalProperties = new(StringComparer.Ordinal); + if (factoryOptions.AdditionalProperties is not null) + { + foreach (var (key, value) in factoryOptions.AdditionalProperties) + { + additionalProperties[key] = value; + } + } + + if (toolOptions.OverridesBuiltInTool) + { + additionalProperties[OverridesBuiltInToolKey] = true; + } + + if (toolOptions.SkipPermission) + { + additionalProperties[SkipPermissionKey] = true; + } + + factoryOptions.AdditionalProperties = additionalProperties; + } + } + } + +} + +/// +/// Copilot-specific options for tools defined with . +/// +public sealed class CopilotToolOptions +{ + /// + /// Gets or sets a value indicating whether this tool intentionally overrides a built-in Copilot tool with the same name. + /// + /// + /// When a with set to true is used to define a tool, + /// the resulting will include "is_override": true in its . + /// + public bool OverridesBuiltInTool { get; set; } + + /// + /// Gets or sets a value indicating whether this tool can execute without a permission prompt. + /// + /// + /// When a with set to true is used to define a tool, + /// the resulting will include "skip_permission": true in its . + /// + public bool SkipPermission { get; set; } +} diff --git a/dotnet/test/Unit/CopilotToolTests.cs b/dotnet/test/Unit/CopilotToolTests.cs new file mode 100644 index 000000000..e1cb228fe --- /dev/null +++ b/dotnet/test/Unit/CopilotToolTests.cs @@ -0,0 +1,138 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using Microsoft.Extensions.AI; +using System.ComponentModel; +using System.Text.Json; +using Xunit; + +namespace GitHub.Copilot.SDK.Test.Unit; + +public class CopilotToolTests +{ + [Fact] + public void DefineTool_Sets_Name_Description_And_Copilot_Metadata() + { + var function = CopilotTool.DefineTool( + ReturnsOk, + new CopilotToolOptions + { + OverridesBuiltInTool = true, + SkipPermission = true + }); + + Assert.Equal("test_tool", function.Name); + Assert.Equal("Test tool", function.Description); + Assert.True(function.AdditionalProperties.TryGetValue("is_override", out var isOverride)); + Assert.True((bool)isOverride!); + Assert.True(function.AdditionalProperties.TryGetValue("skip_permission", out var skipPermission)); + Assert.True((bool)skipPermission!); + } + + [Fact] + public void DefineTool_Omits_Copilot_Metadata_When_Flags_Are_False() + { + var function = CopilotTool.DefineTool(ReturnsOk); + + Assert.False(function.AdditionalProperties.ContainsKey("is_override")); + Assert.False(function.AdditionalProperties.ContainsKey("skip_permission")); + } + + [Fact] + public void DefineTool_Accepts_Lambda_Handlers_Without_Casts() + { + var function = CopilotTool.DefineTool((string value) => value, factoryOptions: new() { Name = "echo", Description = "Echo a value" }); + + Assert.Equal("echo", function.Name); + } + + [Fact] + public async Task DefineTool_Binds_ToolInvocation_And_Excludes_It_From_Schema() + { + var function = CopilotTool.DefineTool( + (string value, ToolInvocation invocation) => $"{value}:{invocation.ToolName}", + factoryOptions: new() { Name = "echo", Description = "Echo a value" }); + + var schema = function.JsonSchema.GetRawText(); + Assert.Contains("\"value\"", schema); + Assert.DoesNotContain("\"invocation\"", schema); + + using var document = JsonDocument.Parse("\"hello\""); + var result = await function.InvokeAsync(new AIFunctionArguments + { + ["value"] = document.RootElement.Clone(), + Context = new Dictionary + { + [typeof(ToolInvocation)] = new ToolInvocation { ToolName = "echo" } + } + }); + + Assert.Equal("hello:echo", Assert.IsType(result).GetString()); + } + + [Fact] + public async Task DefineTool_Preserves_Custom_Parameter_Binding() + { + var function = CopilotTool.DefineTool( + (string value, string suffix, ToolInvocation invocation) => $"{value}:{suffix}:{invocation.ToolName}", + factoryOptions: new() + { + Name = "echo", + Description = "Echo a value", + ConfigureParameterBinding = pi => + pi.Name == "suffix" + ? new AIFunctionFactoryOptions.ParameterBindingOptions + { + ExcludeFromSchema = true, + BindParameter = static (_, _) => "bound" + } + : default + }); + + var schema = function.JsonSchema.GetRawText(); + Assert.Contains("\"value\"", schema); + Assert.DoesNotContain("\"suffix\"", schema); + Assert.DoesNotContain("\"invocation\"", schema); + + using var document = JsonDocument.Parse("\"hello\""); + var result = await function.InvokeAsync(new AIFunctionArguments + { + ["value"] = document.RootElement.Clone(), + Context = new Dictionary + { + [typeof(ToolInvocation)] = new ToolInvocation { ToolName = "echo" } + } + }); + + Assert.Equal("hello:bound:echo", Assert.IsType(result).GetString()); + } + + [Fact] + public void DefineTool_Preserves_Additional_Properties_And_ToolOptions_Take_Precedence() + { + var function = CopilotTool.DefineTool( + ReturnsOk, + new CopilotToolOptions + { + SkipPermission = true + }, + new AIFunctionFactoryOptions + { + Name = "test_tool", + AdditionalProperties = new Dictionary + { + ["custom"] = 42, + ["skip_permission"] = false, + } + }); + + Assert.Equal(42, function.AdditionalProperties["custom"]); + Assert.True(function.AdditionalProperties.TryGetValue("skip_permission", out var skipPermission)); + Assert.True((bool)skipPermission!); + } + + [DisplayName("test_tool")] + [Description("Test tool")] + private static string ReturnsOk() => "ok"; +} diff --git a/test/scenarios/tools/tool-overrides/README.md b/test/scenarios/tools/tool-overrides/README.md index 45f75dc86..cb15f45b5 100644 --- a/test/scenarios/tools/tool-overrides/README.md +++ b/test/scenarios/tools/tool-overrides/README.md @@ -15,7 +15,7 @@ Demonstrates how to override a built-in tool with a custom implementation using | `tools` | Custom `grep` tool | Provides a custom grep implementation | | `overridesBuiltInTool` | `true` | Tells the SDK to disable the built-in `grep` in favor of the custom one | -The flag is set per-tool in TypeScript (`overridesBuiltInTool: true`), Python (`overrides_built_in_tool=True`), and Go (`OverridesBuiltInTool: true`). In C#, set `is_override` in the tool's `AdditionalProperties` via `AIFunctionFactoryOptions`. +The flag is set per-tool in TypeScript (`overridesBuiltInTool: true`), Python (`overrides_built_in_tool=True`), Go (`OverridesBuiltInTool: true`), and .NET (`new CopilotToolOptions { OverridesBuiltInTool = true }`). ## Run diff --git a/test/scenarios/tools/tool-overrides/csharp/Program.cs b/test/scenarios/tools/tool-overrides/csharp/Program.cs index 42ad433fe..be8c07ec8 100644 --- a/test/scenarios/tools/tool-overrides/csharp/Program.cs +++ b/test/scenarios/tools/tool-overrides/csharp/Program.cs @@ -1,4 +1,3 @@ -using System.Collections.ObjectModel; using System.ComponentModel; using GitHub.Copilot.SDK; using Microsoft.Extensions.AI; @@ -17,11 +16,13 @@ { Model = "claude-haiku-4.5", OnPermissionRequest = PermissionHandler.ApproveAll, - Tools = [AIFunctionFactory.Create((Delegate)CustomGrep, new AIFunctionFactoryOptions + Tools = [CopilotTool.DefineTool((Delegate)CustomGrep, new CopilotToolOptions + { + OverridesBuiltInTool = true + }, new AIFunctionFactoryOptions { Name = "grep", - AdditionalProperties = new ReadOnlyDictionary( - new Dictionary { ["is_override"] = true }) + Description = "A custom grep implementation that overrides the built-in", })], }); From 0adad132e8a5cffd9617234d46f023c656941274 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 22:33:37 -0400 Subject: [PATCH 2/2] Fix .NET tool helper docs wording Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/CopilotTool.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dotnet/src/CopilotTool.cs b/dotnet/src/CopilotTool.cs index ab2ea7396..6adcee093 100644 --- a/dotnet/src/CopilotTool.cs +++ b/dotnet/src/CopilotTool.cs @@ -27,8 +27,8 @@ public static class CopilotTool /// /// This is a helper on top of that applies additional configuration to support /// Copilot tools, such as binding a parameter and adding Copilot-specific metadata properties based on the provided - /// . Any may be used as a Copilot tool; this helper simply provides additional conveniences - /// when tools that opt-in to using advanced features. + /// . Any may be used as a Copilot tool; this helper simply provides additional conveniences + /// for tools that opt in to advanced features. /// public static AIFunction DefineTool( Delegate method,