From 6bd2cfec03a3f4cd6599da9f0b314e9de2786f60 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 5 Jun 2026 18:43:07 +0100 Subject: [PATCH] .NET: [BREAKING] Add auto-approval rules (heuristics) to ToolApprovalAgent (#6335) * Add support for approving tools via heuristic rules * Address PR comments * Address PR comments * Apply suggestion from @SergeyMenshykh Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> --------- Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> --- .../HarnessAgent.cs | 2 +- .../HarnessAgentOptions.cs | 9 + .../Harness/ToolApproval/ToolApprovalAgent.cs | 114 +++++-- .../ToolApprovalAgentBuilderExtensions.cs | 11 +- .../ToolApproval/ToolApprovalAgentOptions.cs | 45 +++ .../HarnessAgentTests.cs | 45 +++ ...ToolApprovalAgentBuilderExtensionsTests.cs | 6 +- .../ToolApproval/ToolApprovalAgentTests.cs | 313 +++++++++++++++++- 8 files changed, 497 insertions(+), 48 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgentOptions.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgent.cs b/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgent.cs index 139f47db3c..b3d19f65cb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgent.cs @@ -138,7 +138,7 @@ public sealed class HarnessAgent : DelegatingAIAgent if (options?.DisableToolApproval is not true) { - builder.UseToolApproval(); + builder.UseToolApproval(options?.ToolApprovalAgentOptions); } if (options?.DisableOpenTelemetry is not true) diff --git a/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgentOptions.cs index 46c64cfe2f..924bd90e85 100644 --- a/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgentOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgentOptions.cs @@ -101,6 +101,15 @@ public sealed class HarnessAgentOptions /// public bool DisableToolApproval { get; set; } + /// + /// Gets or sets the options for the middleware. + /// + /// + /// When , the uses default settings. + /// This property has no effect when is . + /// + public ToolApprovalAgentOptions? ToolApprovalAgentOptions { get; set; } + /// /// Gets or sets a value indicating whether the is disabled. /// diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgent.cs b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgent.cs index 8512f686ec..0c9e36f392 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgent.cs @@ -51,20 +51,22 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent { private readonly ProviderSessionState _sessionState; private readonly JsonSerializerOptions _jsonSerializerOptions; + private readonly Func>[]? _autoApprovalRules; /// /// Initializes a new instance of the class. /// /// The underlying agent to delegate to. - /// - /// Optional used for serializing argument values when storing rules - /// and for persisting state. When , is used. + /// + /// Optional for configuring serialization and auto-approval rules. + /// When , default settings are used. /// /// is . - public ToolApprovalAgent(AIAgent innerAgent, JsonSerializerOptions? jsonSerializerOptions = null) + public ToolApprovalAgent(AIAgent innerAgent, ToolApprovalAgentOptions? options = null) : base(innerAgent) { - this._jsonSerializerOptions = jsonSerializerOptions ?? AgentJsonUtilities.DefaultOptions; + this._jsonSerializerOptions = options?.JsonSerializerOptions ?? AgentJsonUtilities.DefaultOptions; + this._autoApprovalRules = options?.AutoApprovalRules?.ToArray(); this._sessionState = new ProviderSessionState( _ => new ToolApprovalState(), "toolApprovalState", @@ -79,7 +81,7 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent CancellationToken cancellationToken = default) { // Steps 1–2: Unwrap AlwaysApprove wrappers, process any queued approval requests. - var (state, callerMessages, nextQueuedItem) = this.PrepareInboundMessages(messages, session); + var (state, callerMessages, nextQueuedItem) = await this.PrepareInboundMessagesAsync(messages, session).ConfigureAwait(false); if (nextQueuedItem is not null) { @@ -98,7 +100,7 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent var response = await this.InnerAgent.RunAsync(processedMessages, session, options, cancellationToken).ConfigureAwait(false); // Classify approval requests: auto-approve matching, queue excess, keep first unapproved. - bool allAutoApproved = this.ProcessAndQueueOutboundApprovalRequests(response.Messages, state, session); + bool allAutoApproved = await this.ProcessAndQueueOutboundApprovalRequestsAsync(response.Messages, state, session).ConfigureAwait(false); if (!allAutoApproved) { @@ -119,7 +121,7 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent [EnumeratorCancellation] CancellationToken cancellationToken = default) { // Steps 1–2: Unwrap AlwaysApprove wrappers, process any queued approval requests. - var (state, callerMessages, nextQueuedItem) = this.PrepareInboundMessages(messages, session); + var (state, callerMessages, nextQueuedItem) = await this.PrepareInboundMessagesAsync(messages, session).ConfigureAwait(false); if (nextQueuedItem is not null) { @@ -197,7 +199,7 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent yield break; } - // 4. Classify the collected approval requests against standing rules. + // 4. Classify the collected approval requests against standing rules and auto-approval rules. List unapproved = []; foreach (var tarc in streamedApprovalRequests) { @@ -206,6 +208,11 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent state.CollectedApprovalResponses.Add( tarc.CreateResponse(approved: true, reason: "Auto-approved by standing rule")); } + else if (await this.MatchesAutoApprovalRuleAsync(tarc).ConfigureAwait(false)) + { + state.CollectedApprovalResponses.Add( + tarc.CreateResponse(approved: true, reason: "Auto-approved by auto-approval rule")); + } else { unapproved.Add(tarc); @@ -291,9 +298,9 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent } /// - /// Re-evaluates queued approval requests against current rules and auto-approves any that now match. + /// Re-evaluates queued approval requests against current rules and auto-approval rules, and auto-approves any that now match. /// - private void DrainAutoApprovableFromQueue(ToolApprovalState state) + private async ValueTask DrainAutoApprovableFromQueueAsync(ToolApprovalState state) { for (int i = state.QueuedApprovalRequests.Count - 1; i >= 0; i--) { @@ -303,6 +310,12 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent state.QueuedApprovalRequests[i].CreateResponse(approved: true, reason: "Auto-approved by standing rule")); state.QueuedApprovalRequests.RemoveAt(i); } + else if (await this.MatchesAutoApprovalRuleAsync(state.QueuedApprovalRequests[i]).ConfigureAwait(false)) + { + state.CollectedApprovalResponses.Add( + state.QueuedApprovalRequests[i].CreateResponse(approved: true, reason: "Auto-approved by auto-approval rule")); + state.QueuedApprovalRequests.RemoveAt(i); + } } } @@ -318,8 +331,8 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent /// A tuple of (state, processed caller messages, next queued item or if the queue is resolved). /// When the returned item is non-null, the caller should return/yield it without calling the inner agent. /// - private (ToolApprovalState State, List CallerMessages, ToolApprovalRequestContent? NextQueuedItem) - PrepareInboundMessages(IEnumerable messages, AgentSession? session) + private async ValueTask<(ToolApprovalState State, List CallerMessages, ToolApprovalRequestContent? NextQueuedItem)> + PrepareInboundMessagesAsync(IEnumerable messages, AgentSession? session) { var state = this._sessionState.GetOrInitializeState(session); @@ -337,7 +350,7 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent // Re-evaluate remaining queued items — the caller may have added new rules // (e.g., "always approve this tool") that resolve additional items. - this.DrainAutoApprovableFromQueue(state); + await this.DrainAutoApprovableFromQueueAsync(state).ConfigureAwait(false); if (state.QueuedApprovalRequests.Count > 0) { @@ -386,15 +399,18 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent /// if all TARc items were auto-approved (caller should re-invoke the inner agent); /// otherwise. /// - private bool ProcessAndQueueOutboundApprovalRequests( + private async ValueTask ProcessAndQueueOutboundApprovalRequestsAsync( IList responseMessages, ToolApprovalState state, AgentSession? session) { - // Pass 1: Scan all response messages and classify each approval request as - // auto-approved (matches a standing rule) or unapproved (needs caller decision). - var autoApproved = new List(); + // Pass 1: Scan all response messages and classify each approval request. + // Auto-approved requests (matching a standing rule or auto-approval rule) have their + // responses collected immediately, preserving the original request order, and are + // marked for removal. Unapproved requests are collected for the caller to decide. + var toRemove = new HashSet(); var unapproved = new List(); + int autoApprovedCount = 0; foreach (var message in responseMessages) { @@ -404,7 +420,17 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent { if (MatchesRule(tarc, state.Rules, this._jsonSerializerOptions)) { - autoApproved.Add(tarc); + state.CollectedApprovalResponses.Add( + tarc.CreateResponse(approved: true, reason: "Auto-approved by standing rule")); + toRemove.Add(tarc); + autoApprovedCount++; + } + else if (await this.MatchesAutoApprovalRuleAsync(tarc).ConfigureAwait(false)) + { + state.CollectedApprovalResponses.Add( + tarc.CreateResponse(approved: true, reason: "Auto-approved by auto-approval rule")); + toRemove.Add(tarc); + autoApprovedCount++; } else { @@ -415,18 +441,12 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent } // Nothing to process: no auto-approved items and at most one unapproved (no queueing needed). - if (autoApproved.Count == 0 && unapproved.Count <= 1) + // No responses were collected above in this case, so state is unmodified and safe to leave. + if (autoApprovedCount == 0 && unapproved.Count <= 1) { return false; } - // Store auto-approved responses for later injection into the inner agent. - foreach (var tarc in autoApproved) - { - state.CollectedApprovalResponses.Add( - tarc.CreateResponse(approved: true, reason: "Auto-approved by standing rule")); - } - // If every approval request was auto-approved, strip them all and signal the caller // to re-invoke the inner agent immediately with the collected responses. if (unapproved.Count == 0) @@ -439,14 +459,10 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent // Pass 2: Keep only the first unapproved request in the response (for the caller to decide). // Queue the remaining unapproved requests for subsequent one-at-a-time delivery. // Remove all auto-approved and queued items from the response messages. - var toRemove = new HashSet(autoApproved); - if (unapproved.Count > 1) + for (int i = 1; i < unapproved.Count; i++) { - for (int i = 1; i < unapproved.Count; i++) - { - toRemove.Add(unapproved[i]); - state.QueuedApprovalRequests.Add(unapproved[i]); - } + toRemove.Add(unapproved[i]); + state.QueuedApprovalRequests.Add(unapproved[i]); } // Walk messages in reverse and strip marked items. @@ -663,8 +679,36 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent } /// - /// Compares stored rule arguments against actual function call arguments for an exact match. + /// Checks whether a is approved by any of the configured + /// auto-approval rules (heuristic functions). /// + /// + /// if any auto-approval rule returns for the function call; + /// if no rules are configured, the request is not a function call, or no rule approves it. + /// + private async ValueTask MatchesAutoApprovalRuleAsync(ToolApprovalRequestContent request) + { + if (this._autoApprovalRules is not { Length: > 0 }) + { + return false; + } + + if (request.ToolCall is not FunctionCallContent functionCall) + { + return false; + } + + foreach (var rule in this._autoApprovalRules) + { + if (await rule(functionCall).ConfigureAwait(false)) + { + return true; + } + } + + return false; + } + private static bool ArgumentsMatch(IDictionary ruleArguments, IDictionary? callArguments, JsonSerializerOptions jsonSerializerOptions) { if (callArguments is null) diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgentBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgentBuilderExtensions.cs index ec92bb8d6c..ea0a2b658f 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgentBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgentBuilderExtensions.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System.Diagnostics.CodeAnalysis; -using System.Text.Json; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; @@ -17,9 +16,9 @@ public static class ToolApprovalAgentBuilderExtensions /// Adds tool approval middleware to the agent pipeline, enabling "don't ask again" approval behavior. /// /// The to which tool approval support will be added. - /// - /// Optional used for serializing argument values when storing rules - /// and for persisting state. When , is used. + /// + /// Optional for configuring serialization and auto-approval rules. + /// When , default settings are used. /// /// The with tool approval middleware added, enabling method chaining. /// is . @@ -32,6 +31,6 @@ public static class ToolApprovalAgentBuilderExtensions /// public static AIAgentBuilder UseToolApproval( this AIAgentBuilder builder, - JsonSerializerOptions? jsonSerializerOptions = null) - => Throw.IfNull(builder).Use(innerAgent => new ToolApprovalAgent(innerAgent, jsonSerializerOptions)); + ToolApprovalAgentOptions? options = null) + => Throw.IfNull(builder).Use(innerAgent => new ToolApprovalAgent(innerAgent, options)); } diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgentOptions.cs new file mode 100644 index 0000000000..974e7398a9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgentOptions.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Options for configuring the middleware. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public class ToolApprovalAgentOptions +{ + /// + /// Gets or sets the used for serializing argument values + /// when storing rules and for persisting state. + /// + /// + /// When , is used. + /// + public JsonSerializerOptions? JsonSerializerOptions { get; set; } + + /// + /// Gets or sets a collection of heuristic functions that can automatically approve function calls + /// that would otherwise require user approval. + /// + /// + /// + /// Each function receives a representing the tool call that requires approval + /// and returns a that resolves to to auto-approve + /// the call, or to continue evaluating the next rule. + /// + /// + /// Auto-approval rules are evaluated after standing rules (derived from prior user approvals) but before + /// prompting the user. Rules are evaluated in order; the first rule returning + /// causes the function call to be auto-approved. + /// + /// + public IEnumerable>>? AutoApprovalRules { get; set; } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentTests.cs index 2711fa1458..da3899d663 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentTests.cs @@ -644,6 +644,51 @@ public class HarnessAgentTests Assert.Null(agent.GetService()); } + /// + /// Verify that ToolApprovalAgentOptions auto-approval rules are passed through and actually used. + /// + [Fact] + public async Task ToolApproval_AutoApprovalRulesAreAppliedAsync() + { + // Arrange — inner client returns an approval request on first call, then final response on second. + var callCount = 0; + var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ReadTool")); + + var mockClient = new Mock(); + mockClient + .Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(() => + { + callCount++; + if (callCount == 1) + { + return new ChatResponse(new ChatMessage(ChatRole.Assistant, [approvalRequest])); + } + + return new ChatResponse(new ChatMessage(ChatRole.Assistant, "Done")); + }); + + var options = CreateAllDisabledOptions(); + options.DisableToolApproval = false; + options.ToolApprovalAgentOptions = new ToolApprovalAgentOptions + { + AutoApprovalRules = [fcc => new ValueTask(fcc.Name == "ReadTool")] + }; + + 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 — the auto-approval rule approved the request, so we get "Done" (not an approval request) + Assert.Equal(2, callCount); + Assert.Equal("Done", response.Text); + } + #endregion #region Feature: OpenTelemetry diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/ToolApprovalAgentBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/ToolApprovalAgentBuilderExtensionsTests.cs index 9e43d2c0dc..e940bf612d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/ToolApprovalAgentBuilderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/ToolApprovalAgentBuilderExtensionsTests.cs @@ -59,15 +59,15 @@ public class ToolApprovalAgentBuilderExtensionsTests /// Verify that UseToolApproval with custom JsonSerializerOptions works correctly. /// [Fact] - public void UseToolApproval_WithCustomJsonSerializerOptions_ReturnsToolApprovalAgent() + public void UseToolApproval_WithCustomOptions_ReturnsToolApprovalAgent() { // Arrange var mockAgent = new Mock(); var builder = new AIAgentBuilder(mockAgent.Object); - var options = new JsonSerializerOptions(); + var options = new ToolApprovalAgentOptions { JsonSerializerOptions = new JsonSerializerOptions() }; // Act - var result = builder.UseToolApproval(jsonSerializerOptions: options).Build(); + var result = builder.UseToolApproval(options: options).Build(); // Assert Assert.IsType(result); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/ToolApprovalAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/ToolApprovalAgentTests.cs index 41c7c473aa..6e497d8bea 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/ToolApprovalAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/ToolApprovalAgentTests.cs @@ -47,14 +47,14 @@ public class ToolApprovalAgentTests } /// - /// Verify that constructor accepts custom JsonSerializerOptions. + /// Verify that constructor accepts custom options. /// [Fact] - public void Constructor_CustomJsonSerializerOptions_CreatesInstanceAsync() + public void Constructor_CustomOptions_CreatesInstance() { // Arrange var innerAgent = new Mock().Object; - var options = new JsonSerializerOptions(); + var options = new ToolApprovalAgentOptions { JsonSerializerOptions = new JsonSerializerOptions() }; // Act var agent = new ToolApprovalAgent(innerAgent, options); @@ -1535,4 +1535,311 @@ public class ToolApprovalAgentTests } #endregion + + #region Auto-Approval Rules (Heuristics) + + /// + /// Verify that an auto-approval rule can approve a function call that would otherwise need user approval. + /// + [Fact] + public async Task RunAsync_AutoApprovalRule_ApprovesMatchingToolAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ReadTool")); + + // Inner agent: first call returns approval request, second returns final response. + var callCount = 0; + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => + { + callCount++; + if (callCount == 1) + { + return new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalRequest])]); + } + + return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done")]); + }); + + var options = new ToolApprovalAgentOptions + { + AutoApprovalRules = [fcc => new ValueTask(fcc.Name == "ReadTool")] + }; + var agent = new ToolApprovalAgent(innerAgent.Object, options); + + // Act + var response = await agent.RunAsync( + [new ChatMessage(ChatRole.User, "Hi")], + session); + + // Assert — the approval request was auto-approved, inner agent called twice + Assert.Equal(2, callCount); + Assert.Equal("Done", response.Text); + } + + /// + /// Verify that when auto-approval rule does not match, request is surfaced to the caller. + /// + [Fact] + public async Task RunAsync_AutoApprovalRule_DoesNotMatchSurfacesToCallerAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "DangerousTool")); + + var innerAgent = CreateMockAgent(new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalRequest])])); + + var options = new ToolApprovalAgentOptions + { + AutoApprovalRules = [fcc => new ValueTask(fcc.Name == "ReadTool")] // Only approves ReadTool + }; + var agent = new ToolApprovalAgent(innerAgent.Object, options); + + // Act + var response = await agent.RunAsync( + [new ChatMessage(ChatRole.User, "Hi")], + session); + + // Assert — request surfaced to caller since heuristic doesn't match + var requests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); + Assert.Single(requests); + Assert.Equal("DangerousTool", ((FunctionCallContent)requests[0].ToolCall).Name); + } + + /// + /// Verify that multiple auto-approval rules are evaluated in order; first match wins. + /// + [Fact] + public async Task RunAsync_MultipleAutoApprovalRules_FirstMatchWinsAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "SpecialTool")); + + var callCount = 0; + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => + { + callCount++; + if (callCount == 1) + { + return new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalRequest])]); + } + + return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done")]); + }); + + var rule1Called = false; + var rule2Called = false; + var options = new ToolApprovalAgentOptions + { + AutoApprovalRules = + [ + fcc => { rule1Called = true; return new ValueTask(fcc.Name == "SpecialTool"); }, + fcc => { rule2Called = true; return new ValueTask(true); } // Should not be reached + ] + }; + var agent = new ToolApprovalAgent(innerAgent.Object, options); + + // Act + await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session); + + // Assert — first rule matched, second was never called + Assert.True(rule1Called); + Assert.False(rule2Called); + } + + /// + /// Verify that standing rules are evaluated before auto-approval rules. + /// + [Fact] + public async Task RunAsync_StandingRuleTakesPrecedenceOverAutoApprovalRuleAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool")); + + var callCount = 0; + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => + { + callCount++; + if (callCount <= 2) + { + return new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalRequest])]); + } + + return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done")]); + }); + + var heuristicCalled = false; + var options = new ToolApprovalAgentOptions + { + AutoApprovalRules = [fcc => { heuristicCalled = true; return new ValueTask(true); }] + }; + var agent = new ToolApprovalAgent(innerAgent.Object, options); + + // Call 1: heuristic should be called (no standing rule yet) + var response1 = await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session); + Assert.True(heuristicCalled); + Assert.Equal("Done", response1.Text); + + // Now establish a standing rule by sending AlwaysApprove + heuristicCalled = false; + callCount = 0; + var alwaysApprove = new AlwaysApproveToolApprovalResponseContent( + approvalRequest.CreateResponse(approved: true), + alwaysApproveTool: true, + alwaysApproveToolWithArguments: false); + + // Call 2: standing rule should match first, heuristic should NOT be called + var response2 = await agent.RunAsync( + [new ChatMessage(ChatRole.User, [alwaysApprove])], + session); + Assert.False(heuristicCalled); + Assert.Equal("Done", response2.Text); + } + + /// + /// Verify that when a batch contains a mix of heuristic-approved and standing-rule-approved + /// requests, the collected approval responses preserve the original request order rather than + /// being grouped by approval kind. + /// + [Fact] + public async Task RunAsync_MixedAutoApprovals_PreserveOriginalOrderAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + + // Batch ordering: first request is approved by a heuristic, second by a standing rule. + var heuristicRequest = new ToolApprovalRequestContent("reqA", new FunctionCallContent("callA", "HeuristicTool")); + var standingRequest = new ToolApprovalRequestContent("reqB", new FunctionCallContent("callB", "StandingTool")); + + var batchResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [heuristicRequest, standingRequest])]); + var finalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done")]); + + var callCount = 0; + List? secondCallMessages = null; + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback, AgentSession?, AgentRunOptions?, CancellationToken>((msgs, _, _, _) => + { + callCount++; + if (callCount == 2) + { + secondCallMessages = msgs.ToList(); + } + }) + .ReturnsAsync(() => callCount == 1 ? batchResponse : finalResponse); + + var options = new ToolApprovalAgentOptions + { + AutoApprovalRules = [fcc => new ValueTask(fcc.Name == "HeuristicTool")] + }; + var agent = new ToolApprovalAgent(innerAgent.Object, options); + + // Establish a standing rule for "StandingTool" via an AlwaysApprove response in the same call. + var alwaysApprove = standingRequest.CreateAlwaysApproveToolResponse("User said always"); + + // Act — both requests auto-approve (heuristic + standing rule), so the inner agent is re-invoked. + var response = await agent.RunAsync( + [new ChatMessage(ChatRole.User, [alwaysApprove])], + session); + + // Assert — inner agent re-called and final response returned. + Assert.Equal(2, callCount); + Assert.Equal("Done", response.Text); + + // The injected approval responses must preserve the original request order: reqA before reqB, + // even though reqA was approved by a heuristic and reqB by a standing rule. + Assert.NotNull(secondCallMessages); + var injected = secondCallMessages! + .SelectMany(m => m.Contents) + .OfType() + .Where(r => r.RequestId is "reqA" or "reqB") + .ToList(); + Assert.Equal(2, injected.Count); + Assert.Equal("reqA", injected[0].RequestId); + Assert.Equal("reqB", injected[1].RequestId); + Assert.All(injected, r => Assert.True(r.Approved)); + } + + /// + /// Verify that auto-approval rules work in the streaming path. + /// + [Fact] + public async Task RunStreamingAsync_AutoApprovalRule_ApprovesMatchingToolAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ReadTool")); + + var callCount = 0; + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreStreamingAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Returns(() => + { + callCount++; + if (callCount == 1) + { + return ToAsyncEnumerableAsync([new AgentResponseUpdate(ChatRole.Assistant, [approvalRequest])]); + } + + return ToAsyncEnumerableAsync([new AgentResponseUpdate(ChatRole.Assistant, "Done")]); + }); + + var options = new ToolApprovalAgentOptions + { + AutoApprovalRules = [fcc => new ValueTask(fcc.Name == "ReadTool")] + }; + var agent = new ToolApprovalAgent(innerAgent.Object, options); + + // Act + var updates = new List(); + await foreach (var update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "Hi")], session)) + { + updates.Add(update); + } + + // Assert — the approval request was auto-approved, inner agent streamed twice + Assert.Equal(2, callCount); + Assert.Single(updates); + Assert.Equal("Done", updates[0].Text); + } + + #endregion }