.NET: Add approval bypassing to harness as the default (#6387)

* Add approval bypassing to harness as a default

* Add tests

* Address PR comments.
This commit is contained in:
westey
2026-06-08 18:50:41 +01:00
committed by GitHub
Unverified
parent 9bc7b27813
commit b343625c1f
4 changed files with 116 additions and 2 deletions
@@ -178,8 +178,14 @@ public sealed class HarnessAgent : DelegatingAIAgent
IEnumerable<AIContextProvider> 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)
@@ -110,6 +110,20 @@ public sealed class HarnessAgentOptions
/// </remarks>
public ToolApprovalAgentOptions? ToolApprovalAgentOptions { get; set; }
/// <summary>
/// Gets or sets a value indicating whether bypassing of approval requests for tools that do not
/// require approval is disabled.
/// </summary>
/// <remarks>
/// When <see langword="false"/> (the default), the underlying chat client pipeline includes the decorator
/// added by <see cref="ChatClientBuilderExtensions.UseNonApprovalRequiredFunctionBypassing"/> 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.
/// </remarks>
public bool DisableNonApprovalRequiredFunctionBypassing { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the <see cref="FileMemoryProvider"/> is disabled.
/// </summary>
@@ -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);
@@ -691,6 +691,97 @@ public class HarnessAgentTests
#endregion
#region Feature: NonApprovalRequiredFunctionBypassing
/// <summary>
/// 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 <c>NonApprovalRequiredFunctionBypassingChatClient</c> decorator.
/// </summary>
[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<IChatClient>();
mockClient
.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.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<ToolApprovalRequestContent>()
.ToList();
var approvalRequest = Assert.Single(approvalRequests);
Assert.Equal("ApprovalTool", Assert.IsType<FunctionCallContent>(approvalRequest.ToolCall).Name);
}
/// <summary>
/// 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 <see cref="FunctionInvokingChatClient"/>.
/// </summary>
[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<IChatClient>();
mockClient
.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.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<ToolApprovalRequestContent>()
.Select(r => ((FunctionCallContent)r.ToolCall).Name)
.ToList();
Assert.Equal(2, approvalRequests.Count);
Assert.Contains("NormalTool", approvalRequests);
Assert.Contains("ApprovalTool", approvalRequests);
}
#endregion
#region Feature: OpenTelemetry
/// <summary>