.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>
This commit is contained in:
westey
2026-06-05 18:43:07 +01:00
committed by GitHub
Unverified
parent ab8ba8fc61
commit 6bd2cfec03
8 changed files with 497 additions and 48 deletions
@@ -644,6 +644,51 @@ public class HarnessAgentTests
Assert.Null(agent.GetService<ToolApprovalAgent>());
}
/// <summary>
/// Verify that ToolApprovalAgentOptions auto-approval rules are passed through and actually used.
/// </summary>
[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<IChatClient>();
mockClient
.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.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<bool>(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
@@ -59,15 +59,15 @@ public class ToolApprovalAgentBuilderExtensionsTests
/// Verify that UseToolApproval with custom JsonSerializerOptions works correctly.
/// </summary>
[Fact]
public void UseToolApproval_WithCustomJsonSerializerOptions_ReturnsToolApprovalAgent()
public void UseToolApproval_WithCustomOptions_ReturnsToolApprovalAgent()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
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<ToolApprovalAgent>(result);
@@ -47,14 +47,14 @@ public class ToolApprovalAgentTests
}
/// <summary>
/// Verify that constructor accepts custom JsonSerializerOptions.
/// Verify that constructor accepts custom options.
/// </summary>
[Fact]
public void Constructor_CustomJsonSerializerOptions_CreatesInstanceAsync()
public void Constructor_CustomOptions_CreatesInstance()
{
// Arrange
var innerAgent = new Mock<AIAgent>().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)
/// <summary>
/// Verify that an auto-approval rule can approve a function call that would otherwise need user approval.
/// </summary>
[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<AIAgent>();
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.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<bool>(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);
}
/// <summary>
/// Verify that when auto-approval rule does not match, request is surfaced to the caller.
/// </summary>
[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<bool>(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<ToolApprovalRequestContent>().ToList();
Assert.Single(requests);
Assert.Equal("DangerousTool", ((FunctionCallContent)requests[0].ToolCall).Name);
}
/// <summary>
/// Verify that multiple auto-approval rules are evaluated in order; first match wins.
/// </summary>
[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<AIAgent>();
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.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<bool>(fcc.Name == "SpecialTool"); },
fcc => { rule2Called = true; return new ValueTask<bool>(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);
}
/// <summary>
/// Verify that standing rules are evaluated before auto-approval rules.
/// </summary>
[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<AIAgent>();
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.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<bool>(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);
}
/// <summary>
/// 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.
/// </summary>
[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<ChatMessage>? secondCallMessages = null;
var innerAgent = new Mock<AIAgent>();
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Callback<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>((msgs, _, _, _) =>
{
callCount++;
if (callCount == 2)
{
secondCallMessages = msgs.ToList();
}
})
.ReturnsAsync(() => callCount == 1 ? batchResponse : finalResponse);
var options = new ToolApprovalAgentOptions
{
AutoApprovalRules = [fcc => new ValueTask<bool>(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<ToolApprovalResponseContent>()
.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));
}
/// <summary>
/// Verify that auto-approval rules work in the streaming path.
/// </summary>
[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<AIAgent>();
innerAgent
.Protected()
.Setup<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.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<bool>(fcc.Name == "ReadTool")]
};
var agent = new ToolApprovalAgent(innerAgent.Object, options);
// Act
var updates = new List<AgentResponseUpdate>();
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
}