diff --git a/dotnet/samples/02-agents/AgentProviders/github-copilot/Agent_With_GitHubCopilot/README.md b/dotnet/samples/02-agents/AgentProviders/github-copilot/Agent_With_GitHubCopilot/README.md index 90266f38bc7..623caa2636f 100644 --- a/dotnet/samples/02-agents/AgentProviders/github-copilot/Agent_With_GitHubCopilot/README.md +++ b/dotnet/samples/02-agents/AgentProviders/github-copilot/Agent_With_GitHubCopilot/README.md @@ -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: diff --git a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs index 006e191b3eb..dc057097857 100644 --- a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs @@ -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; @@ -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; /// /// Initializes a new instance of the class. @@ -42,6 +45,15 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable /// The name of the agent. /// The description of the agent. /// Optional JSON serializer options. Defaults to . + /// Optional logger factory used to create the agent's logger. + /// + /// When a tool wrapped in is registered and the supplied + /// does not already define a Hooks.OnPreToolUse handler, the agent installs a + /// default OnPreToolUse hook that returns "ask" for those tools (routing the decision to + /// SessionConfig.OnPermissionRequest) and defers all other tools. If the caller supplies their own + /// OnPreToolUse 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. + /// public GitHubCopilotAgent( CopilotClient copilotClient, SessionConfig? sessionConfig = null, @@ -49,12 +61,14 @@ public GitHubCopilotAgent( 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(); + this._sessionConfig = ConfigureApprovalHook(sessionConfig, this._logger); this._ownsClient = ownsClient; this._id = id; this._name = name ?? DefaultName; @@ -73,6 +87,12 @@ public GitHubCopilotAgent( /// The tools to make available to the agent. /// Optional instructions to append as a system message. /// Optional JSON serializer options. Defaults to . + /// Optional logger factory used to create the agent's logger. + /// + /// When a tool wrapped in is registered, the agent installs a default + /// Hooks.OnPreToolUse handler that returns "ask" for those tools (routing the decision to + /// SessionConfig.OnPermissionRequest) and defers all other tools. + /// public GitHubCopilotAgent( CopilotClient copilotClient, bool ownsClient = false, @@ -81,7 +101,8 @@ public GitHubCopilotAgent( string? description = null, IList? tools = null, string? instructions = null, - JsonSerializerOptions? jsonSerializerOptions = null) + JsonSerializerOptions? jsonSerializerOptions = null, + ILoggerFactory? loggerFactory = null) : this( copilotClient, GetSessionConfig(tools, instructions), @@ -89,7 +110,8 @@ public GitHubCopilotAgent( id, name, description, - jsonSerializerOptions) + jsonSerializerOptions, + loggerFactory) { } @@ -519,6 +541,91 @@ private AgentResponseUpdate ConvertToAgentResponseUpdate(SessionEvent sessionEve return new SessionConfig { Tools = mappedTools, SystemMessage = systemMessage }; } + /// + /// Installs a default OnPreToolUse hook that gates tools wrapped in + /// by returning "ask" (routing the decision to SessionConfig.OnPermissionRequest) while deferring all + /// other tools, so the GitHub Copilot SDK enforces approval through its native pre-tool-use hook. + /// + /// + /// The source is returned unchanged when it contains no approval-required tools. + /// If the caller already supplied a Hooks.OnPreToolUse 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 ) so the caller-supplied configuration is not mutated. + /// + private static SessionConfig? ConfigureApprovalHook(SessionConfig? sessionConfig, ILogger logger) + { + if (sessionConfig?.Tools is not { Count: > 0 } tools) + { + return sessionConfig; + } + + HashSet approvalRequiredToolNames = new(StringComparer.Ordinal); + foreach (AIFunctionDeclaration tool in tools) + { + if (tool is AIFunction function && function.GetService() 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; + } + + /// + /// Creates a shallow copy of a instance, preserving all configured hook delegates. + /// + 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? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync( IEnumerable messages, CancellationToken cancellationToken) diff --git a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/GitHubCopilotAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/GitHubCopilotAgentTests.cs index cfdc8198725..c1599012f09 100644 --- a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/GitHubCopilotAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/GitHubCopilotAgentTests.cs @@ -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 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() { diff --git a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentTests.cs index 8faa842eb04..fe632a0d796 100644 --- a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentTests.cs @@ -7,6 +7,7 @@ using GitHub.Copilot; using GitHub.Copilot.Rpc; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.GitHub.Copilot.UnitTests; @@ -382,4 +383,175 @@ public void ConvertToAgentResponseUpdate_AssistantMessageEventWhenNotStreaming_H Assert.Null(result.MessageId); Assert.Null(result.ResponseId); } + + [Fact] + public async Task Constructor_WithApprovalRequiredTool_InstallsAskPreToolUseHookAsync() + { + // Arrange + AIFunction dangerousTool = AIFunctionFactory.Create(() => "sensitive", "ApprovalRequiredOperation", "A sensitive operation."); + AIFunction plainTool = AIFunctionFactory.Create(() => "ok", "PlainOperation", "A normal operation."); + CopilotClient copilotClient = new(new CopilotClientOptions()); + + // Act + var agent = new GitHubCopilotAgent(copilotClient, tools: [new ApprovalRequiredAIFunction(dangerousTool), plainTool]); + + // Assert - the provider installs a default OnPreToolUse that asks for the approval-required tool + // (routing the decision to OnPermissionRequest) and defers everything else. + SessionConfig sessionConfig = GetSessionConfigFromAgent(agent); + Assert.NotNull(sessionConfig.Hooks?.OnPreToolUse); + + PreToolUseHookOutput? approvalDecision = await InvokePreToolUseAsync(sessionConfig, "ApprovalRequiredOperation"); + Assert.Equal("ask", approvalDecision?.PermissionDecision); + Assert.False(string.IsNullOrEmpty(approvalDecision?.PermissionDecisionReason)); + + PreToolUseHookOutput? plainDecision = await InvokePreToolUseAsync(sessionConfig, "PlainOperation"); + Assert.Null(plainDecision); + } + + [Fact] + public async Task Constructor_WithApprovalRequiredToolInSessionConfig_InstallsAskPreToolUseHookAsync() + { + // Arrange + AIFunction dangerousTool = AIFunctionFactory.Create(() => "sensitive", "ApprovalRequiredOperation", "A sensitive operation."); + SessionConfig sessionConfig = new() { Tools = [new ApprovalRequiredAIFunction(dangerousTool)] }; + CopilotClient copilotClient = new(new CopilotClientOptions()); + + // Act + var agent = new GitHubCopilotAgent(copilotClient, sessionConfig); + + // Assert + SessionConfig configured = GetSessionConfigFromAgent(agent); + Assert.NotNull(configured.Hooks?.OnPreToolUse); + PreToolUseHookOutput? decision = await InvokePreToolUseAsync(configured, "ApprovalRequiredOperation"); + Assert.Equal("ask", decision?.PermissionDecision); + } + + [Fact] + public void Constructor_WithNoApprovalRequiredTools_DoesNotInstallPreToolUseHook() + { + // Arrange + AIFunction plainTool = AIFunctionFactory.Create(() => "ok", "PlainOperation", "A normal operation."); + CopilotClient copilotClient = new(new CopilotClientOptions()); + + // Act + var agent = new GitHubCopilotAgent(copilotClient, tools: [plainTool]); + + // Assert - no approval-required tools means no hook is installed; tools flow through normally. + SessionConfig sessionConfig = GetSessionConfigFromAgent(agent); + Assert.Null(sessionConfig.Hooks?.OnPreToolUse); + } + + [Fact] + public async Task Constructor_WithUserPreToolUseHook_PreservesItAndWarnsAsync() + { + // Arrange - the caller supplies their own OnPreToolUse hook and also registers an approval-required tool. + AIFunction dangerousTool = AIFunctionFactory.Create(() => "sensitive", "ApprovalRequiredOperation", "A sensitive operation."); + var userHookOutput = new PreToolUseHookOutput { PermissionDecision = "allow" }; + SessionConfig sessionConfig = new() + { + Tools = [new ApprovalRequiredAIFunction(dangerousTool)], + Hooks = new SessionHooks + { + OnPreToolUse = (input, invocation) => Task.FromResult(userHookOutput), + }, + }; + + CapturingLoggerFactory loggerFactory = new(); + CopilotClient copilotClient = new(new CopilotClientOptions()); + + // Act + var agent = new GitHubCopilotAgent(copilotClient, sessionConfig, loggerFactory: loggerFactory); + + // Assert - the user's hook is preserved (not overridden), and a warning is logged for the unenforced tool. + SessionConfig configured = GetSessionConfigFromAgent(agent); + PreToolUseHookOutput? decision = await InvokePreToolUseAsync(configured, "ApprovalRequiredOperation"); + Assert.Same(userHookOutput, decision); + + (LogLevel Level, string Message) warning = Assert.Single(loggerFactory.Entries, e => e.Level == LogLevel.Warning); + Assert.Contains("ApprovalRequiredOperation", warning.Message); + } + + [Fact] + public void Constructor_WithUserPreToolUseHookButNoApprovalRequiredTools_DoesNotWarn() + { + // Arrange + AIFunction plainTool = AIFunctionFactory.Create(() => "ok", "PlainOperation", "A normal operation."); + SessionConfig sessionConfig = new() + { + Tools = [plainTool], + Hooks = new SessionHooks + { + OnPreToolUse = (input, invocation) => Task.FromResult(null), + }, + }; + + CapturingLoggerFactory loggerFactory = new(); + CopilotClient copilotClient = new(new CopilotClientOptions()); + + // Act + var agent = new GitHubCopilotAgent(copilotClient, sessionConfig, loggerFactory: loggerFactory); + + // Assert - nothing is being bypassed, so no warning is emitted. + Assert.DoesNotContain(loggerFactory.Entries, e => e.Level == LogLevel.Warning); + } + + [Fact] + public void Constructor_WithApprovalRequiredTool_DoesNotMutateCallerHooks() + { + // Arrange + AIFunction dangerousTool = AIFunctionFactory.Create(() => "sensitive", "ApprovalRequiredOperation", "A sensitive operation."); + SessionHooks callerHooks = new(); + SessionConfig sessionConfig = new() + { + Tools = [new ApprovalRequiredAIFunction(dangerousTool)], + Hooks = callerHooks, + }; + CopilotClient copilotClient = new(new CopilotClientOptions()); + + // Act + _ = new GitHubCopilotAgent(copilotClient, sessionConfig); + + // Assert - the caller-supplied SessionConfig and its Hooks instance are not mutated. + Assert.Null(callerHooks.OnPreToolUse); + Assert.Same(callerHooks, sessionConfig.Hooks); + } + + private static Task InvokePreToolUseAsync(SessionConfig sessionConfig, string toolName) + { + var input = new PreToolUseHookInput { ToolName = toolName }; + return sessionConfig.Hooks!.OnPreToolUse!(input, new HookInvocation()); + } + + private static SessionConfig GetSessionConfigFromAgent(GitHubCopilotAgent agent) + { + System.Reflection.FieldInfo field = typeof(GitHubCopilotAgent).GetField( + "_sessionConfig", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; + return (SessionConfig)field.GetValue(agent)!; + } + + private sealed class CapturingLoggerFactory : ILoggerFactory + { + public List<(LogLevel Level, string Message)> Entries { get; } = []; + + public void AddProvider(ILoggerProvider provider) + { + } + + public ILogger CreateLogger(string categoryName) => new CapturingLogger(this.Entries); + + public void Dispose() + { + } + + private sealed class CapturingLogger(List<(LogLevel Level, string Message)> entries) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + => entries.Add((logLevel, formatter(state, exception))); + } + } }