Skip to content

Commit e8623df

Browse files
stephentoubCopilot
andauthored
Add .NET CopilotTool helper (#1321)
* 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> * Fix .NET tool helper docs wording Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6670eb2 commit e8623df

9 files changed

Lines changed: 334 additions & 38 deletions

File tree

.github/copilot-instructions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535

3636
## Project-specific conventions & patterns ✅
3737

38-
- Tools: each SDK has helper APIs to expose functions as tools; prefer the language's `DefineTool`/`@define_tool`/`AIFunctionFactory.Create` patterns (see language READMEs).
38+
- Tools: each SDK has helper APIs to expose functions as tools; prefer the language's `DefineTool`/`@define_tool`/`CopilotTool.DefineTool` patterns (see language READMEs).
3939
- 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.
4040
- 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.
4141
- 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).

docs/getting-started.md

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1166,7 +1166,7 @@ using System.ComponentModel;
11661166
await using var client = new CopilotClient();
11671167

11681168
// Define a tool that Copilot can call
1169-
var getWeather = AIFunctionFactory.Create(
1169+
var getWeather = CopilotTool.DefineTool(
11701170
([Description("The city name")] string city) =>
11711171
{
11721172
// In a real app, you'd call a weather API here
@@ -1175,8 +1175,11 @@ var getWeather = AIFunctionFactory.Create(
11751175
var condition = conditions[Random.Shared.Next(conditions.Length)];
11761176
return new { city, temperature = $"{temp}°F", condition };
11771177
},
1178-
"get_weather",
1179-
"Get the current weather for a city"
1178+
factoryOptions: new AIFunctionFactoryOptions
1179+
{
1180+
Name = "get_weather",
1181+
Description = "Get the current weather for a city",
1182+
}
11801183
);
11811184

11821185
await using var session = await client.CreateSessionAsync(new SessionConfig
@@ -1648,17 +1651,20 @@ using GitHub.Copilot.SDK;
16481651
using Microsoft.Extensions.AI;
16491652
using System.ComponentModel;
16501653

1651-
// Define the weather tool using AIFunctionFactory
1652-
var getWeather = AIFunctionFactory.Create(
1654+
// Define the weather tool
1655+
var getWeather = CopilotTool.DefineTool(
16531656
([Description("The city name")] string city) =>
16541657
{
16551658
var conditions = new[] { "sunny", "cloudy", "rainy", "partly cloudy" };
16561659
var temp = Random.Shared.Next(50, 80);
16571660
var condition = conditions[Random.Shared.Next(conditions.Length)];
16581661
return new { city, temperature = $"{temp}°F", condition };
16591662
},
1660-
"get_weather",
1661-
"Get the current weather for a city");
1663+
factoryOptions: new AIFunctionFactoryOptions
1664+
{
1665+
Name = "get_weather",
1666+
Description = "Get the current weather for a city",
1667+
});
16621668

16631669
await using var client = new CopilotClient();
16641670
await using var session = await client.CreateSessionAsync(new SessionConfig

docs/integrations/microsoft-agent-framework.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,10 +151,13 @@ using Microsoft.Extensions.AI;
151151
using Microsoft.Agents.AI;
152152

153153
// Define a custom tool
154-
AIFunction weatherTool = AIFunctionFactory.Create(
154+
AIFunction weatherTool = CopilotTool.DefineTool(
155155
(string location) => $"The weather in {location} is sunny with a high of 25°C.",
156-
"GetWeather",
157-
"Get the current weather for a given location."
156+
factoryOptions: new AIFunctionFactoryOptions
157+
{
158+
Name = "GetWeather",
159+
Description = "Get the current weather for a given location.",
160+
}
158161
);
159162

160163
await using var copilotClient = new CopilotClient();

dotnet/README.md

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -425,7 +425,7 @@ await client.StopAsync();
425425

426426
### Tools
427427

428-
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:
428+
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:
429429

430430
```csharp
431431
using Microsoft.Extensions.AI;
@@ -435,34 +435,39 @@ var session = await client.CreateSessionAsync(new SessionConfig
435435
{
436436
Model = "gpt-5",
437437
Tools = [
438-
AIFunctionFactory.Create(
438+
CopilotTool.DefineTool(
439439
async ([Description("Issue identifier")] string id) => {
440440
var issue = await FetchIssueAsync(id);
441441
return issue;
442442
},
443-
"lookup_issue",
444-
"Fetch issue details from our tracker"),
443+
factoryOptions: new AIFunctionFactoryOptions
444+
{
445+
Name = "lookup_issue",
446+
Description = "Fetch issue details from our tracker",
447+
}),
445448
]
446449
});
447450
```
448451

449-
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.
452+
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.
450453

451454
#### Overriding Built-in Tools
452455

453-
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.
456+
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.
454457

455458
```csharp
456-
var editFile = AIFunctionFactory.Create(
459+
var editFile = CopilotTool.DefineTool(
457460
async ([Description("File path")] string path, [Description("New content")] string content) => {
458461
// your logic
459462
},
460-
"edit_file",
461-
"Custom file editor with project-specific validation",
462-
new AIFunctionFactoryOptions
463+
toolOptions: new CopilotToolOptions
464+
{
465+
OverridesBuiltInTool = true
466+
},
467+
factoryOptions: new AIFunctionFactoryOptions
463468
{
464-
AdditionalProperties = new ReadOnlyDictionary<string, object?>(
465-
new Dictionary<string, object?> { ["is_override"] = true })
469+
Name = "edit_file",
470+
Description = "Custom file editor with project-specific validation",
466471
});
467472

468473
var session = await client.CreateSessionAsync(new SessionConfig
@@ -474,22 +479,28 @@ var session = await client.CreateSessionAsync(new SessionConfig
474479

475480
#### Skipping Permission Prompts
476481

477-
Set `skip_permission` in the tool's `AdditionalProperties` to allow it to execute without triggering a permission prompt:
482+
Set `CopilotToolOptions.SkipPermission` to allow a tool to execute without triggering a permission prompt:
478483

479484
```csharp
480-
var safeLookup = AIFunctionFactory.Create(
485+
var safeLookup = CopilotTool.DefineTool(
481486
async ([Description("Lookup ID")] string id) => {
482487
// your logic
483488
},
484-
"safe_lookup",
485-
"A read-only lookup that needs no confirmation",
486-
new AIFunctionFactoryOptions
489+
toolOptions: new CopilotToolOptions
487490
{
488-
AdditionalProperties = new ReadOnlyDictionary<string, object?>(
489-
new Dictionary<string, object?> { ["skip_permission"] = true })
491+
SkipPermission = true
492+
},
493+
factoryOptions: new AIFunctionFactoryOptions
494+
{
495+
Name = "safe_lookup",
496+
Description = "A read-only lookup that needs no confirmation",
490497
});
491498
```
492499

500+
`DefineTool` delegates to `AIFunctionFactory.Create`, so advanced `AIFunctionFactoryOptions` remain available through the overload that accepts both `AIFunctionFactoryOptions` and `CopilotToolOptions`.
501+
502+
If you want to use `AIFunctionFactory.Create` directly, you can set `skip_permission` in the tool's `AdditionalProperties`.
503+
493504
## Commands
494505

495506
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
789800

790801
### Per-Tool Skip Permission
791802

792-
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.
803+
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.
793804

794805
## User Input Requests
795806

dotnet/src/Client.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2012,8 +2012,8 @@ internal record ToolDefinition(
20122012
{
20132013
public static ToolDefinition FromAIFunction(AIFunction function)
20142014
{
2015-
var overrides = function.AdditionalProperties.TryGetValue("is_override", out var val) && val is true;
2016-
var skipPerm = function.AdditionalProperties.TryGetValue("skip_permission", out var skipVal) && skipVal is true;
2015+
var overrides = function.AdditionalProperties.TryGetValue(CopilotTool.OverridesBuiltInToolKey, out var val) && val is true;
2016+
var skipPerm = function.AdditionalProperties.TryGetValue(CopilotTool.SkipPermissionKey, out var skipVal) && skipVal is true;
20172017
return new ToolDefinition(function.Name, function.Description, function.JsonSchema,
20182018
overrides ? true : null,
20192019
skipPerm ? true : null);

dotnet/src/CopilotTool.cs

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
using Microsoft.Extensions.AI;
6+
7+
namespace GitHub.Copilot.SDK;
8+
9+
/// <summary>
10+
/// Provides helpers for defining Copilot tools.
11+
/// </summary>
12+
public static class CopilotTool
13+
{
14+
/// <summary>The key used in <see cref="AITool.AdditionalProperties"/> to indicate that a tool intentionally overrides a built-in Copilot tool with the same name.</summary>
15+
internal const string OverridesBuiltInToolKey = "is_override";
16+
17+
/// <summary>The key used in <see cref="AITool.AdditionalProperties"/> to indicate that a tool can execute without a permission prompt.</summary>
18+
internal const string SkipPermissionKey = "skip_permission";
19+
20+
/// <summary>
21+
/// Defines a tool for use in a <see cref="CopilotSession"/>.
22+
/// </summary>
23+
/// <param name="method">The delegate to invoke when the tool is called.</param>
24+
/// <param name="factoryOptions">The Microsoft.Extensions.AI options used to create the function.</param>
25+
/// <param name="toolOptions">Copilot-specific tool options.</param>
26+
/// <returns>An <see cref="AIFunction"/> that can be added to <see cref="SessionConfig.Tools"/> or <see cref="ResumeSessionConfig.Tools"/>.</returns>
27+
/// <remarks>
28+
/// This is a helper on top of <see cref="AIFunctionFactory.Create(Delegate, AIFunctionFactoryOptions)"/> that applies additional configuration to support
29+
/// Copilot tools, such as binding a <see cref="ToolInvocation"/> parameter and adding Copilot-specific metadata properties based on the provided
30+
/// <see cref="CopilotToolOptions"/>. Any <see cref="AIFunction"/> may be used as a Copilot tool; this helper simply provides additional conveniences
31+
/// for tools that opt in to advanced features.
32+
/// </remarks>
33+
public static AIFunction DefineTool(
34+
Delegate method,
35+
CopilotToolOptions? toolOptions = null,
36+
AIFunctionFactoryOptions? factoryOptions = null)
37+
{
38+
ArgumentNullException.ThrowIfNull(method);
39+
40+
factoryOptions ??= new();
41+
42+
ApplyToolOptions(factoryOptions, toolOptions);
43+
ApplyToolInvocationBinding(factoryOptions);
44+
45+
return AIFunctionFactory.Create(method, factoryOptions);
46+
47+
static void ApplyToolInvocationBinding(AIFunctionFactoryOptions factoryOptions)
48+
{
49+
var configureParameterBinding = factoryOptions.ConfigureParameterBinding;
50+
factoryOptions.ConfigureParameterBinding = pi =>
51+
{
52+
var bindingOptions = configureParameterBinding?.Invoke(pi) ?? default;
53+
54+
if (bindingOptions.BindParameter is null &&
55+
!bindingOptions.ExcludeFromSchema &&
56+
pi.ParameterType == typeof(ToolInvocation))
57+
{
58+
return new AIFunctionFactoryOptions.ParameterBindingOptions
59+
{
60+
ExcludeFromSchema = true,
61+
BindParameter = static (pi, arguments) =>
62+
{
63+
// CopilotClient/CopilotSession attach this context object before invoking the AIFunction.
64+
if (arguments.Context is not null &&
65+
arguments.Context.TryGetValue(typeof(ToolInvocation), out var invocation) &&
66+
invocation is ToolInvocation toolInvocation)
67+
{
68+
return toolInvocation;
69+
}
70+
71+
if (pi.HasDefaultValue)
72+
{
73+
return null;
74+
}
75+
76+
throw new InvalidOperationException($"No {nameof(ToolInvocation)} was provided for the tool call.");
77+
}
78+
};
79+
}
80+
81+
return bindingOptions;
82+
};
83+
}
84+
85+
static void ApplyToolOptions(AIFunctionFactoryOptions factoryOptions, CopilotToolOptions? toolOptions)
86+
{
87+
if (toolOptions is not null && (toolOptions.OverridesBuiltInTool || toolOptions.SkipPermission))
88+
{
89+
Dictionary<string, object?> additionalProperties = new(StringComparer.Ordinal);
90+
if (factoryOptions.AdditionalProperties is not null)
91+
{
92+
foreach (var (key, value) in factoryOptions.AdditionalProperties)
93+
{
94+
additionalProperties[key] = value;
95+
}
96+
}
97+
98+
if (toolOptions.OverridesBuiltInTool)
99+
{
100+
additionalProperties[OverridesBuiltInToolKey] = true;
101+
}
102+
103+
if (toolOptions.SkipPermission)
104+
{
105+
additionalProperties[SkipPermissionKey] = true;
106+
}
107+
108+
factoryOptions.AdditionalProperties = additionalProperties;
109+
}
110+
}
111+
}
112+
113+
}
114+
115+
/// <summary>
116+
/// Copilot-specific options for tools defined with <see cref="CopilotTool"/>.
117+
/// </summary>
118+
public sealed class CopilotToolOptions
119+
{
120+
/// <summary>
121+
/// Gets or sets a value indicating whether this tool intentionally overrides a built-in Copilot tool with the same name.
122+
/// </summary>
123+
/// <remarks>
124+
/// When a <see cref="CopilotToolOptions"/> with <see cref="OverridesBuiltInTool"/> set to true is used to define a tool,
125+
/// the resulting <see cref="AIFunction"/> will include "is_override": true in its <see cref="AITool.AdditionalProperties"/>.
126+
/// </remarks>
127+
public bool OverridesBuiltInTool { get; set; }
128+
129+
/// <summary>
130+
/// Gets or sets a value indicating whether this tool can execute without a permission prompt.
131+
/// </summary>
132+
/// <remarks>
133+
/// When a <see cref="CopilotToolOptions"/> with <see cref="SkipPermission"/> set to true is used to define a tool,
134+
/// the resulting <see cref="AIFunction"/> will include "skip_permission": true in its <see cref="AITool.AdditionalProperties"/>.
135+
/// </remarks>
136+
public bool SkipPermission { get; set; }
137+
}

0 commit comments

Comments
 (0)