From b343625c1ff54b344e7facdf59b51003bd787921 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Mon, 8 Jun 2026 18:50:41 +0100 Subject: [PATCH] .NET: Add approval bypassing to harness as the default (#6387) * Add approval bypassing to harness as a default * Add tests * Address PR comments. --- .../HarnessAgent.cs | 10 +- .../HarnessAgentOptions.cs | 14 +++ .../HarnessAgentOptionsTests.cs | 3 + .../HarnessAgentTests.cs | 91 +++++++++++++++++++ 4 files changed, 116 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgent.cs b/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgent.cs index b3d19f65cb..6960b755ec 100644 --- a/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgent.cs @@ -178,8 +178,14 @@ public sealed class HarnessAgent : DelegatingAIAgent IEnumerable contextProviders = BuildContextProviders(options, loggerFactory); - return chatClient - .AsBuilder() + ChatClientBuilder chatClientBuilder = chatClient.AsBuilder(); + + if (options?.DisableNonApprovalRequiredFunctionBypassing is not true) + { + chatClientBuilder.UseNonApprovalRequiredFunctionBypassing(); + } + + return chatClientBuilder .UseFunctionInvocation(loggerFactory, configure: options?.MaximumIterationsPerRequest is int maxIterations ? ficc => ficc.MaximumIterationsPerRequest = maxIterations : null) diff --git a/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgentOptions.cs index 924bd90e85..85924b7c3e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgentOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgentOptions.cs @@ -110,6 +110,20 @@ public sealed class HarnessAgentOptions /// public ToolApprovalAgentOptions? ToolApprovalAgentOptions { get; set; } + /// + /// Gets or sets a value indicating whether bypassing of approval requests for tools that do not + /// require approval is disabled. + /// + /// + /// When (the default), the underlying chat client pipeline includes the decorator + /// added by above the + /// function invocation middleware. + /// This stores automatically approved function calls for tools that do not require approval in the session + /// state when they are returned alongside tools that do, so that only tools that truly require human + /// approval are surfaced to the caller. + /// + public bool DisableNonApprovalRequiredFunctionBypassing { get; set; } + /// /// Gets or sets a value indicating whether the is disabled. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentOptionsTests.cs index b876ecaea4..90387b7b54 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentOptionsTests.cs @@ -27,6 +27,7 @@ public class HarnessAgentOptionsTests Assert.Null(options.ChatHistoryProvider); Assert.Null(options.AIContextProviders); Assert.False(options.DisableToolApproval); + Assert.False(options.DisableNonApprovalRequiredFunctionBypassing); Assert.False(options.DisableFileMemory); Assert.False(options.DisableFileAccess); Assert.False(options.DisableWebSearch); @@ -80,6 +81,7 @@ public class HarnessAgentOptionsTests AIContextProviders = contextProviders, MaximumIterationsPerRequest = 42, DisableToolApproval = true, + DisableNonApprovalRequiredFunctionBypassing = true, DisableFileMemory = true, FileMemoryStore = fileMemoryStore, DisableFileAccess = true, @@ -112,6 +114,7 @@ public class HarnessAgentOptionsTests Assert.Same(contextProviders, options.AIContextProviders); Assert.Equal(42, options.MaximumIterationsPerRequest); Assert.True(options.DisableToolApproval); + Assert.True(options.DisableNonApprovalRequiredFunctionBypassing); Assert.True(options.DisableFileMemory); Assert.Same(fileMemoryStore, options.FileMemoryStore); Assert.True(options.DisableFileAccess); diff --git a/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentTests.cs index da3899d663..f7977b595f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentTests.cs @@ -691,6 +691,97 @@ public class HarnessAgentTests #endregion + #region Feature: NonApprovalRequiredFunctionBypassing + + /// + /// Verify that by default, when a response contains a mix of tools that require approval and tools that do not, + /// only the approval-required tool is surfaced to the caller. The non-approval-required tool is bypassed + /// (stored as auto-approved) by the NonApprovalRequiredFunctionBypassingChatClient decorator. + /// + [Fact] + public async Task NonApprovalRequiredFunctionBypassing_BypassesNonApprovalToolsByDefaultAsync() + { + // Arrange — the model requests both a normal tool and an approval-required tool in the same turn. + var normalTool = AIFunctionFactory.Create(() => "result", "NormalTool"); + var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "ApprovalTool")); + + var mockClient = new Mock(); + mockClient + .Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(() => new ChatResponse(new ChatMessage(ChatRole.Assistant, + [ + new FunctionCallContent("call1", "NormalTool"), + new FunctionCallContent("call2", "ApprovalTool"), + ]))); + + // Disable ToolApproval so the approval requests surface in the response instead of being handled. + var options = CreateAllDisabledOptions(); + options.ChatOptions = new ChatOptions { Tools = [normalTool, approvalTool] }; + + var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options); + var session = await agent.CreateSessionAsync(); + + // Act + var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session); + + // Assert — only the approval-required tool surfaces as an approval request; the normal tool is bypassed. + var approvalRequests = response.Messages + .SelectMany(m => m.Contents) + .OfType() + .ToList(); + var approvalRequest = Assert.Single(approvalRequests); + Assert.Equal("ApprovalTool", Assert.IsType(approvalRequest.ToolCall).Name); + } + + /// + /// Verify that when bypassing is disabled, all tools (including those that do not require approval) are surfaced + /// as approval requests, reflecting the all-or-nothing behavior of . + /// + [Fact] + public async Task NonApprovalRequiredFunctionBypassing_SurfacesAllApprovalsWhenDisabledAsync() + { + // Arrange — the model requests both a normal tool and an approval-required tool in the same turn. + var normalTool = AIFunctionFactory.Create(() => "result", "NormalTool"); + var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "ApprovalTool")); + + var mockClient = new Mock(); + mockClient + .Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(() => new ChatResponse(new ChatMessage(ChatRole.Assistant, + [ + new FunctionCallContent("call1", "NormalTool"), + new FunctionCallContent("call2", "ApprovalTool"), + ]))); + + var options = CreateAllDisabledOptions(); + options.DisableNonApprovalRequiredFunctionBypassing = true; + options.ChatOptions = new ChatOptions { Tools = [normalTool, approvalTool] }; + + var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options); + var session = await agent.CreateSessionAsync(); + + // Act + var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session); + + // Assert — both tools surface as approval requests because bypassing is disabled. + var approvalRequests = response.Messages + .SelectMany(m => m.Contents) + .OfType() + .Select(r => ((FunctionCallContent)r.ToolCall).Name) + .ToList(); + Assert.Equal(2, approvalRequests.Count); + Assert.Contains("NormalTool", approvalRequests); + Assert.Contains("ApprovalTool", approvalRequests); + } + + #endregion + #region Feature: OpenTelemetry ///