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
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,43 @@ AgentResponse response = await agent.RunAsync("Write a small .NET 10 C# hello wo
Console.WriteLine(response);
```

## Approving or denying tool execution

The GitHub Copilot SDK owns the tool-calling loop for this provider, so approval is enforced through the SDK's
native pre-execution hook rather than the Agent Framework chat-client approval round-trip.

When you register a tool wrapped in `ApprovalRequiredAIFunction`, `GitHubCopilotAgent` installs a default
`SessionConfig.Hooks.OnPreToolUse` hook that returns `"ask"` for that tool and defers (`null`) for all other tools.
The `"ask"` decision routes to your `SessionConfig.OnPermissionRequest` handler, where you approve or deny the call
(this also fires even for tools configured with `SkipPermission = true`):

```csharp
using GitHub.Copilot;

AIFunction deleteFile = AIFunctionFactory.Create(DeleteFile, "DeleteFile", "Deletes a file.");

SessionConfig sessionConfig = new()
{
// Wrapping the tool marks it approval-required; the agent turns this into an "ask" at OnPreToolUse.
Tools = [new ApprovalRequiredAIFunction(deleteFile)],

// OnPermissionRequest decides the "asked" tools (and Copilot's built-in shell/file/URL prompts).
OnPermissionRequest = (request, invocation) =>
{
// Surface to a human, check policy, etc.
bool approved = AskHuman(request);
return Task.FromResult(approved
? PermissionDecision.ApproveOnce()
: PermissionDecision.Reject("Denied by user."));
},
};
```

> **⚠️ If you provide your own `OnPreToolUse` hook**, it takes precedence and the agent does **not** install its
> default approval hook. In that case **you are fully responsible** for enforcing approval — including for any
> `ApprovalRequiredAIFunction` you register (e.g. by returning a `"deny"` or `"ask"` `PreToolUseHookOutput`). The
> agent logs a warning when it detects an approval-required tool that your hook must handle.

## Streaming Responses

To get streaming responses:
Expand Down
115 changes: 111 additions & 4 deletions dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
using System.Threading.Tasks;
using GitHub.Copilot;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.Diagnostics;

namespace Microsoft.Agents.AI.GitHub.Copilot;
Expand All @@ -31,6 +33,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
private readonly SessionConfig? _sessionConfig;
private readonly bool _ownsClient;
private readonly JsonSerializerOptions _jsonSerializerOptions;
private readonly ILogger _logger;

/// <summary>
/// Initializes a new instance of the <see cref="GitHubCopilotAgent"/> class.
Expand All @@ -42,19 +45,30 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
/// <param name="name">The name of the agent.</param>
/// <param name="description">The description of the agent.</param>
/// <param name="jsonSerializerOptions">Optional JSON serializer options. Defaults to <see cref="GitHubCopilotJsonUtilities.DefaultOptions"/>.</param>
/// <param name="loggerFactory">Optional logger factory used to create the agent's logger.</param>
/// <remarks>
/// When a tool wrapped in <see cref="ApprovalRequiredAIFunction"/> is registered and the supplied
/// <paramref name="sessionConfig"/> does not already define a <c>Hooks.OnPreToolUse</c> handler, the agent installs a
/// default <c>OnPreToolUse</c> hook that returns <c>"ask"</c> for those tools (routing the decision to
/// <c>SessionConfig.OnPermissionRequest</c>) and defers all other tools. If the caller supplies their own
/// <c>OnPreToolUse</c> hook, it takes precedence and the caller is fully responsible for approval handling; in that
/// case a warning is logged for any approval-required tool that will not be automatically gated.
/// </remarks>
public GitHubCopilotAgent(
CopilotClient copilotClient,
SessionConfig? sessionConfig = null,
bool ownsClient = false,
string? id = null,
string? name = null,
string? description = null,
JsonSerializerOptions? jsonSerializerOptions = null)
JsonSerializerOptions? jsonSerializerOptions = null,
ILoggerFactory? loggerFactory = null)
{
_ = Throw.IfNull(copilotClient);

this._copilotClient = copilotClient;
this._sessionConfig = sessionConfig;
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<GitHubCopilotAgent>();
this._sessionConfig = ConfigureApprovalHook(sessionConfig, this._logger);
this._ownsClient = ownsClient;
this._id = id;
this._name = name ?? DefaultName;
Expand All @@ -73,6 +87,12 @@ public GitHubCopilotAgent(
/// <param name="tools">The tools to make available to the agent.</param>
/// <param name="instructions">Optional instructions to append as a system message.</param>
/// <param name="jsonSerializerOptions">Optional JSON serializer options. Defaults to <see cref="GitHubCopilotJsonUtilities.DefaultOptions"/>.</param>
/// <param name="loggerFactory">Optional logger factory used to create the agent's logger.</param>
/// <remarks>
/// When a tool wrapped in <see cref="ApprovalRequiredAIFunction"/> is registered, the agent installs a default
/// <c>Hooks.OnPreToolUse</c> handler that returns <c>"ask"</c> for those tools (routing the decision to
/// <c>SessionConfig.OnPermissionRequest</c>) and defers all other tools.
/// </remarks>
public GitHubCopilotAgent(
CopilotClient copilotClient,
bool ownsClient = false,
Expand All @@ -81,15 +101,17 @@ public GitHubCopilotAgent(
string? description = null,
IList<AITool>? tools = null,
string? instructions = null,
JsonSerializerOptions? jsonSerializerOptions = null)
JsonSerializerOptions? jsonSerializerOptions = null,
ILoggerFactory? loggerFactory = null)
: this(
copilotClient,
GetSessionConfig(tools, instructions),
ownsClient,
id,
name,
description,
jsonSerializerOptions)
jsonSerializerOptions,
loggerFactory)
{
}

Expand Down Expand Up @@ -519,6 +541,91 @@ private AgentResponseUpdate ConvertToAgentResponseUpdate(SessionEvent sessionEve
return new SessionConfig { Tools = mappedTools, SystemMessage = systemMessage };
}

/// <summary>
/// Installs a default <c>OnPreToolUse</c> hook that gates tools wrapped in <see cref="ApprovalRequiredAIFunction"/>
/// by returning <c>"ask"</c> (routing the decision to <c>SessionConfig.OnPermissionRequest</c>) while deferring all
/// other tools, so the GitHub Copilot SDK enforces approval through its native pre-tool-use hook.
/// </summary>
/// <remarks>
/// The source <paramref name="sessionConfig"/> is returned unchanged when it contains no approval-required tools.
/// If the caller already supplied a <c>Hooks.OnPreToolUse</c> handler, it takes precedence and is left untouched; a
/// warning is logged for any approval-required tool that will therefore not be automatically gated. Otherwise a
/// clone is returned (with a fresh <see cref="SessionHooks"/>) so the caller-supplied configuration is not mutated.
/// </remarks>
private static SessionConfig? ConfigureApprovalHook(SessionConfig? sessionConfig, ILogger logger)
{
if (sessionConfig?.Tools is not { Count: > 0 } tools)
{
return sessionConfig;
}

HashSet<string> approvalRequiredToolNames = new(StringComparer.Ordinal);
foreach (AIFunctionDeclaration tool in tools)
{
if (tool is AIFunction function && function.GetService<ApprovalRequiredAIFunction>() is not null)
{
approvalRequiredToolNames.Add(function.Name);
}
}

if (approvalRequiredToolNames.Count == 0)
{
return sessionConfig;
}

// A caller-supplied OnPreToolUse hook takes precedence and is fully responsible for approval handling.
// Warn so the developer knows the ApprovalRequiredAIFunction marker(s) will not be automatically gated.
if (sessionConfig.Hooks?.OnPreToolUse is not null)
{
logger.LogWarning(
"A custom 'OnPreToolUse' hook is configured on the SessionConfig, so {Count} approval-required tool(s) ({Tools}) " +
"will not be automatically gated by GitHubCopilotAgent. The custom hook is responsible for enforcing approval " +
"(for example, by returning a 'deny' or 'ask' PreToolUseHookOutput).",
approvalRequiredToolNames.Count,
string.Join(", ", approvalRequiredToolNames));
return sessionConfig;
}

SessionConfig configured = sessionConfig.Clone();

// SessionConfig.Clone() shallow-copies Hooks, so build a fresh SessionHooks (preserving any other hooks)
// to avoid mutating the caller's instance when setting OnPreToolUse.
SessionHooks hooks = CloneHooks(configured.Hooks);
hooks.OnPreToolUse = (input, invocation) =>
Task.FromResult(
approvalRequiredToolNames.Contains(input.ToolName)
? new PreToolUseHookOutput
{
PermissionDecision = "ask",
PermissionDecisionReason = $"Tool '{input.ToolName}' is marked as requiring approval (ApprovalRequiredAIFunction).",
}
: null);
configured.Hooks = hooks;

return configured;
}

/// <summary>
/// Creates a shallow copy of a <see cref="SessionHooks"/> instance, preserving all configured hook delegates.
/// </summary>
private static SessionHooks CloneHooks(SessionHooks? source)
{
SessionHooks clone = new();
if (source is not null)
{
clone.OnPreToolUse = source.OnPreToolUse;
clone.OnPreMcpToolCall = source.OnPreMcpToolCall;
clone.OnPostToolUse = source.OnPostToolUse;
clone.OnPostToolUseFailure = source.OnPostToolUseFailure;
clone.OnUserPromptSubmitted = source.OnUserPromptSubmitted;
clone.OnSessionStart = source.OnSessionStart;
clone.OnSessionEnd = source.OnSessionEnd;
clone.OnErrorOccurred = source.OnErrorOccurred;
}

return clone;
}

private static async Task<(List<AttachmentFile>? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync(
IEnumerable<ChatMessage> messages,
CancellationToken cancellationToken)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,63 @@ public async Task RunAsync_WithFunctionTool_InvokesToolAsync()
}
}

[Fact]
public async Task RunAsync_WithApprovalRequiredTool_AsksAndExecutesWhenPermissionApprovesAsync()
{
// Arrange
SkipIfCopilotNotConfigured();

bool toolInvoked = false;
bool permissionRequested = false;

AIFunction weatherTool = AIFunctionFactory.Create((string location) =>
{
toolInvoked = true;
return $"The weather in {location} is sunny with a high of 25C.";
}, "GetWeather", "Get the weather for a given location.");
ApprovalRequiredAIFunction approvalRequiredTool = new(weatherTool);

Task<PermissionDecision> OnPermissionRequestRecordingAsync(PermissionRequest request, PermissionInvocation invocation)
{
permissionRequested = true;
return Task.FromResult(PermissionDecision.ApproveOnce());
}

await using CopilotClient client = new(new CopilotClientOptions());
await client.StartAsync();

SessionConfig sessionConfig = new()
{
Tools = [approvalRequiredTool],
OnPermissionRequest = OnPermissionRequestRecordingAsync,
SystemMessage = new SystemMessageConfig
{
Mode = SystemMessageMode.Append,
Content = "You are a weather assistant. Always use the GetWeather tool to answer weather questions.",
},
};

await using GitHubCopilotAgent agent = new(client, sessionConfig);
AgentSession session = await agent.CreateSessionAsync();

try
{
// Act
AgentResponse response = await agent.RunAsync("What's the weather like in Seattle?", session);

// Assert - the provider-installed OnPreToolUse returns "ask" for the approval-required tool, routing the
// decision to OnPermissionRequest; the tool only runs because the permission handler approved it.
Assert.NotNull(response);
Assert.NotEmpty(response.Messages);
Assert.True(permissionRequested);
Assert.True(toolInvoked);
}
finally
{
await DeleteSessionAsync(client, session);
}
}

[Fact]
public async Task RunAsync_WithSession_MaintainsContextAsync()
{
Expand Down
Loading
Loading