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
}