.NET: Add always approve helpers, improve sample and fix bug (#5451)

* Add always approve helpers, improve sample and fix bug

* Address PR comments
This commit is contained in:
westey
2026-04-24 10:59:09 +01:00
committed by GitHub
Unverified
parent 025655b573
commit e4595be0c2
15 changed files with 3270 additions and 65 deletions
@@ -61,6 +61,21 @@ public static class HarnessConsole
private static async Task StreamAgentResponseAsync(AIAgent agent, AgentSession session, AgentModeProvider? modeProvider, string userInput, int? maxContextWindowTokens, int? maxOutputTokens)
{
// Initial user input
var approvalRequests = await StreamAndCollectApprovalsAsync(agent.RunStreamingAsync(userInput, session), modeProvider, session, maxContextWindowTokens, maxOutputTokens);
var messagesToSend = PromptForApprovals(approvalRequests);
// Loop while there are approval responses to send back
while (messagesToSend is not null)
{
approvalRequests = await StreamAndCollectApprovalsAsync(agent.RunStreamingAsync(messagesToSend, session), modeProvider, session, maxContextWindowTokens, maxOutputTokens);
messagesToSend = PromptForApprovals(approvalRequests);
}
}
private static async Task<List<ToolApprovalRequestContent>> StreamAndCollectApprovalsAsync(IAsyncEnumerable<AgentResponseUpdate> updates, AgentModeProvider? modeProvider, AgentSession session, int? maxContextWindowTokens, int? maxOutputTokens)
{
var approvalRequests = new List<ToolApprovalRequestContent>();
string mode = modeProvider?.GetMode(session) ?? "unknown";
System.Console.ForegroundColor = GetModeColor(mode);
System.Console.Write($"\n[{mode}] Agent: ");
@@ -72,7 +87,7 @@ public static class HarnessConsole
try
{
await foreach (var update in agent.RunStreamingAsync(userInput, session))
await foreach (var update in updates)
{
foreach (var content in update.Contents)
{
@@ -96,6 +111,17 @@ public static class HarnessConsole
hasTextOutput = false;
spinner.Start();
}
else if (content is ToolApprovalRequestContent approvalRequest)
{
await spinner.StopAsync();
approvalRequests.Add(approvalRequest);
string toolName = approvalRequest.ToolCall is FunctionCallContent fc ? ToolCallFormatter.Format(fc) : approvalRequest.ToolCall?.ToString() ?? "unknown";
System.Console.ForegroundColor = ConsoleColor.Yellow;
System.Console.Write(hasTextOutput ? "\n\n ⚠️ Approval needed: " : "\n ⚠️ Approval needed: ");
System.Console.Write(toolName);
System.Console.ForegroundColor = GetModeColor(mode);
hasTextOutput = false;
}
else if (content is ErrorContent errorContent)
{
await spinner.StopAsync();
@@ -174,7 +200,7 @@ public static class HarnessConsole
await spinner.StopAsync();
if (!hasReceivedAnyText)
if (!hasReceivedAnyText && approvalRequests.Count == 0)
{
System.Console.ForegroundColor = ConsoleColor.DarkYellow;
System.Console.Write("\n (no text response from agent)");
@@ -183,6 +209,59 @@ public static class HarnessConsole
System.Console.ResetColor();
System.Console.WriteLine();
System.Console.WriteLine();
return approvalRequests;
}
/// <summary>
/// Prompts the user for approval of each tool approval request.
/// Returns a list of messages to send back to the agent, or <see langword="null"/> if there are no requests.
/// </summary>
private static List<ChatMessage>? PromptForApprovals(List<ToolApprovalRequestContent> approvalRequests)
{
if (approvalRequests.Count == 0)
{
return null;
}
var responses = new List<AIContent>();
foreach (var request in approvalRequests)
{
string toolName = request.ToolCall is FunctionCallContent fc ? ToolCallFormatter.Format(fc) : request.ToolCall?.ToString() ?? "unknown";
System.Console.ForegroundColor = ConsoleColor.Yellow;
System.Console.WriteLine($"\n 🔐 Tool approval required: {toolName}");
System.Console.ResetColor();
System.Console.WriteLine(" 1) Approve this call");
System.Console.WriteLine(" 2) Always approve this tool (any arguments)");
System.Console.WriteLine(" 3) Always approve this tool with these arguments");
System.Console.WriteLine(" 4) Deny");
System.Console.Write(" Choice [1-4]: ");
string? choice = System.Console.ReadLine()?.Trim();
AIContent response = choice switch
{
"2" => request.CreateAlwaysApproveToolResponse("User chose to always approve this tool"),
"3" => request.CreateAlwaysApproveToolWithArgumentsResponse("User chose to always approve this tool with these arguments"),
"4" => request.CreateResponse(approved: false, reason: "User denied"),
_ => request.CreateResponse(approved: true, reason: "User approved"),
};
string action = choice switch
{
"2" => "✅ Always approved (any args)",
"3" => "✅ Always approved (these args)",
"4" => "❌ Denied",
_ => "✅ Approved",
};
System.Console.ForegroundColor = ConsoleColor.DarkGray;
System.Console.WriteLine($" {action}");
System.Console.ResetColor();
responses.Add(response);
}
return [new ChatMessage(ChatRole.User, responses)];
}
private static void HandleModeCommand(AgentModeProvider? modeProvider, AgentSession session, string input)
@@ -29,31 +29,6 @@ var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYME
const int MaxContextWindowTokens = 1_050_000;
const int MaxOutputTokens = 128_000;
// Create a compaction strategy based on the model's context window.
// gpt-5.4: 1,050,000 token context window, 128,000 max output tokens.
// Defaults: tool result eviction at 50% of input budget, truncation at 80%.
var compactionStrategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: MaxContextWindowTokens,
maxOutputTokens: MaxOutputTokens);
// Create an OpenAIClient that communicates with the Foundry responses service and get an IChatClient with stored output disabled
// so that chat history is managed locally by the agent framework.
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
OpenAIClientOptions clientOptions = new() { Endpoint = new Uri(endpoint), RetryPolicy = new ClientRetryPolicy(3) };
IChatClient chatClient = new OpenAIClient(new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"), clientOptions)
.GetResponsesClient()
.AsIChatClientWithStoredOutputDisabled(deploymentName)
.AsBuilder()
.UseFunctionInvocation()
.UsePerServiceCallChatHistoryPersistence()
.UseAIContextProviders(new CompactionProvider(compactionStrategy))
.Build();
// Create web browsing tools for downloading and converting HTML pages to markdown.
var webBrowsingTools = new WebBrowsingTools();
// Create a ChatClientAgent with the Harness providers (TodoProvider and AgentModeProvider)
// and research-focused instructions including the mandatory planning workflow.
var instructions =
@@ -123,36 +98,70 @@ var instructions =
When a temporary file is no longer needed, delete it to keep file memory tidy.
""";
AIAgent agent = new ChatClientAgent(
chatClient,
new ChatClientAgentOptions
{
Name = "ResearchAgent",
Description = "A research assistant that plans and executes research tasks.",
AIContextProviders =
[
new TodoProvider(),
new AgentModeProvider(),
new FileMemoryProvider(
new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "agent-files")),
(_) => new FileMemoryState() { WorkingFolder = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss") + "_" + Guid.NewGuid().ToString() })
],
RequirePerServiceCallChatHistoryPersistence = true,
UseProvidedChatClientAsIs = true,
ChatHistoryProvider = new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions
// Create a compaction strategy based on the model's context window.
// gpt-5.4: 1,050,000 token context window, 128,000 max output tokens.
// Defaults: tool result eviction at 50% of input budget, truncation at 80%.
var compactionStrategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: MaxContextWindowTokens,
maxOutputTokens: MaxOutputTokens);
AIAgent agent =
// Create an OpenAIClient that communicates with the Foundry responses service.
new OpenAIClient(
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"),
new OpenAIClientOptions()
{
ChatReducer = compactionStrategy.AsChatReducer(),
}),
ChatOptions = new ChatOptions
Endpoint = new Uri(endpoint),
RetryPolicy = new ClientRetryPolicy(3) // Enable retries to improve resiliency.
})
.GetResponsesClient()
.AsIChatClientWithStoredOutputDisabled(deploymentName) // We want to manage chat history locally (not stored in the responses service), so that we can manage compaction ourselves.
// Build a ChatClient Pipeline
.AsBuilder()
.UseFunctionInvocation() // We are building our own stack from scratch so we need to include Function Invocation ourselves.
.UsePerServiceCallChatHistoryPersistence() // Save chat history updates to the session after each service call, rather than only at the end of the run.
.UseAIContextProviders(new CompactionProvider(compactionStrategy)) // Add Compaction before each service call to responses so that long function invocation loops don't overflow the context.
// Build our agent on top of the ChatClient Pipeline
.BuildAIAgent(
new ChatClientAgentOptions
{
// Set a high token limit for long research tasks with many tool calls and long outputs.
// This matches gpt-5.4's max output tokens, and should be adjusted depending on the model used and expected response length.
MaxOutputTokens = 128_000,
Instructions = instructions,
Reasoning = new() { Effort = ReasoningEffort.Medium },
Tools = [ResponseTool.CreateWebSearchTool().AsAITool(), .. webBrowsingTools.Tools],
},
});
Name = "ResearchAgent",
Description = "A research assistant that plans and executes research tasks.",
UseProvidedChatClientAsIs = true, // Since we built our own stack from scratch we need to tell the agent not to also add defaults like Function Invocation.
RequirePerServiceCallChatHistoryPersistence = true, // Since we are added the per service call persistence ChatClient, we need to tell the agent to not also store chat history at the end of the run.
ChatHistoryProvider = new InMemoryChatHistoryProvider( // Store chat history in memory in the session object. Will persist if the session is persisted.
new InMemoryChatHistoryProviderOptions
{
ChatReducer = compactionStrategy.AsChatReducer(), // Run compaction on the InMemory chat history when it gets too large.
}),
AIContextProviders =
[
new TodoProvider(), // Add an AIContextProvider to allow the agent to create a TODO list, which is stored in the session.
new AgentModeProvider(), // Add an AIContextProvider that tracks the agent mode and allows switching mode. Current mode is stored in the session.
new FileMemoryProvider( // Add an AIContextProvider that can store memories in files under a session specific working folder.
new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "agent-files")),
(_) => new FileMemoryState() { WorkingFolder = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss") + "_" + Guid.NewGuid().ToString() })
],
ChatOptions = new ChatOptions
{
Instructions = instructions,
Tools =
[
ResponseTool.CreateWebSearchTool().AsAITool(), // Add the foundry hosted web search tool that runs in the service.
new WebBrowsingTool(), // Add a local web browsing tool that converts html to markdown.
],
MaxOutputTokens = MaxOutputTokens, // Set a high token limit for long research tasks with many tool calls and long outputs.
Reasoning = new() { Effort = ReasoningEffort.Medium },
},
})
.AsBuilder()
.UseToolApproval() // Add the ability to auto approve tools once a user has said they don't want to be asked again. Approval rules are tied to the session.
.Build();
// Run the interactive console session using the shared HarnessConsole helper.
await HarnessConsole.RunAgentAsync(agent, title: "Research Assistant", userPrompt: "Enter a research topic to get started.", maxContextWindowTokens: MaxContextWindowTokens, maxOutputTokens: MaxOutputTokens);
@@ -2,25 +2,34 @@
using System.ComponentModel;
using System.Net;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.Extensions.AI;
namespace SampleApp;
/// <summary>
/// Provides a web browsing tool that downloads HTML pages and converts them to markdown.
/// An AI function that downloads HTML pages and converts them to markdown.
/// </summary>
internal sealed partial class WebBrowsingTools
internal sealed partial class WebBrowsingTool : AIFunction
{
private static readonly HttpClient s_httpClient = new();
private readonly AIFunction _inner = AIFunctionFactory.Create(DownloadUriAsync);
/// <summary>
/// Gets the web browsing tools.
/// </summary>
public IList<AITool> Tools { get; } =
[
AIFunctionFactory.Create(DownloadUriAsync),
];
/// <inheritdoc/>
public override string Name => this._inner.Name;
/// <inheritdoc/>
public override string Description => this._inner.Description;
/// <inheritdoc/>
public override JsonElement JsonSchema => this._inner.JsonSchema;
/// <inheritdoc/>
protected override ValueTask<object?> InvokeCoreAsync(
AIFunctionArguments arguments,
CancellationToken cancellationToken) =>
this._inner.InvokeAsync(arguments, cancellationToken);
[Description("Download the html from the given url as markdown")]
private static async Task<string> DownloadUriAsync(
@@ -81,6 +81,11 @@ internal static partial class AgentJsonUtilities
// AgentModeProvider types
[JsonSerializable(typeof(AgentModeState))]
// ToolApprovalAgent types
[JsonSerializable(typeof(ToolApprovalState))]
[JsonSerializable(typeof(ToolApprovalRule))]
[JsonSerializable(typeof(List<ToolApprovalRule>), TypeInfoPropertyName = "ToolApprovalRuleList")]
// FileMemoryProvider types
[JsonSerializable(typeof(FileMemoryState))]
[JsonSerializable(typeof(FileSearchResult))]
@@ -188,7 +188,7 @@ internal sealed class PerServiceCallChatHistoryPersistingChatClient : Delegating
while (hasUpdates)
{
var update = enumerator.Current;
responseUpdates.Add(update);
responseUpdates.Add(update.Clone());
// If the service returned a real ConversationId on any update, remember that.
// Otherwise stamp our sentinel so FICC treats this as service-managed —
@@ -0,0 +1,67 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Wraps a <see cref="ToolApprovalResponseContent"/> with additional "always approve" settings,
/// enabling the <see cref="ToolApprovalAgent"/> middleware to record standing approval rules
/// so that future matching tool calls are auto-approved without user interaction.
/// </summary>
/// <remarks>
/// <para>
/// Instances of this class should not be created directly. Instead, use the extension methods
/// <see cref="ToolApprovalRequestContentExtensions.CreateAlwaysApproveToolResponse"/> or
/// <see cref="ToolApprovalRequestContentExtensions.CreateAlwaysApproveToolWithArgumentsResponse"/>
/// on <see cref="ToolApprovalRequestContent"/> to create instances with the appropriate flags set.
/// </para>
/// <para>
/// The <see cref="ToolApprovalAgent"/> middleware will unwrap the <see cref="InnerResponse"/> to forward
/// to the inner agent, while extracting the approval settings to persist as <see cref="ToolApprovalRule"/>
/// entries in the session state.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class AlwaysApproveToolApprovalResponseContent : AIContent
{
/// <summary>
/// Initializes a new instance of the <see cref="AlwaysApproveToolApprovalResponseContent"/> class.
/// </summary>
/// <param name="innerResponse">The underlying approval response to forward to the agent.</param>
/// <param name="alwaysApproveTool">
/// When <see langword="true"/>, all future calls to this tool type will be auto-approved.
/// </param>
/// <param name="alwaysApproveToolWithArguments">
/// When <see langword="true"/>, all future calls to this tool type with the same arguments will be auto-approved.
/// </param>
internal AlwaysApproveToolApprovalResponseContent(
ToolApprovalResponseContent innerResponse,
bool alwaysApproveTool,
bool alwaysApproveToolWithArguments)
{
this.InnerResponse = Throw.IfNull(innerResponse);
this.AlwaysApproveTool = alwaysApproveTool;
this.AlwaysApproveToolWithArguments = alwaysApproveToolWithArguments;
}
/// <summary>
/// Gets the underlying <see cref="ToolApprovalResponseContent"/> that will be forwarded to the inner agent.
/// </summary>
public ToolApprovalResponseContent InnerResponse { get; }
/// <summary>
/// Gets a value indicating whether all future calls to the same tool should be auto-approved
/// regardless of the arguments provided.
/// </summary>
public bool AlwaysApproveTool { get; }
/// <summary>
/// Gets a value indicating whether all future calls to the same tool with the exact same
/// arguments should be auto-approved.
/// </summary>
public bool AlwaysApproveToolWithArguments { get; }
}
@@ -0,0 +1,781 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// A <see cref="DelegatingAIAgent"/> middleware that implements "don't ask again" tool approval behavior
/// and queues multiple approval requests to present them to the caller one at a time.
/// </summary>
/// <remarks>
/// <para>
/// This middleware intercepts the approval flow between the caller and the inner agent:
/// </para>
/// <list type="bullet">
/// <item>
/// <b>Outbound (response to caller):</b> When the inner agent surfaces <see cref="ToolApprovalRequestContent"/> items,
/// the middleware checks whether matching <see cref="ToolApprovalRule"/> entries have been recorded. Matched requests
/// are auto-approved and stored as collected approval responses. If multiple unapproved requests remain, only the
/// first is returned to the caller while the rest are queued. On subsequent calls, queued items are re-evaluated
/// against rules (which may have been updated by the caller's "always approve" response) and presented one at a time.
/// Once all queued requests are resolved, the collected responses are injected and the inner agent is called again.
/// </item>
/// <item>
/// <b>Inbound (caller to agent):</b> When the caller sends an <see cref="AlwaysApproveToolApprovalResponseContent"/>,
/// the middleware extracts the standing approval settings, records them as <see cref="ToolApprovalRule"/> entries
/// in the session state, and forwards only the unwrapped <see cref="ToolApprovalResponseContent"/> to the inner agent.
/// Content ordering within each message is preserved.
/// </item>
/// </list>
/// <para>
/// Approval rules are persisted in the <see cref="AgentSessionStateBag"/> and survive across agent runs within the same session.
/// Two categories of rules are supported:
/// </para>
/// <list type="bullet">
/// <item><b>Tool-level:</b> Approve all calls to a specific tool, regardless of arguments.</item>
/// <item><b>Tool+arguments:</b> Approve all calls to a specific tool with exactly matching arguments.</item>
/// </list>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class ToolApprovalAgent : DelegatingAIAgent
{
private readonly ProviderSessionState<ToolApprovalState> _sessionState;
private readonly JsonSerializerOptions _jsonSerializerOptions;
/// <summary>
/// Initializes a new instance of the <see cref="ToolApprovalAgent"/> class.
/// </summary>
/// <param name="innerAgent">The underlying agent to delegate to.</param>
/// <param name="jsonSerializerOptions">
/// Optional <see cref="JsonSerializerOptions"/> used for serializing argument values when storing rules
/// and for persisting state. When <see langword="null"/>, <see cref="AgentJsonUtilities.DefaultOptions"/> is used.
/// </param>
/// <exception cref="ArgumentNullException"><paramref name="innerAgent"/> is <see langword="null"/>.</exception>
public ToolApprovalAgent(AIAgent innerAgent, JsonSerializerOptions? jsonSerializerOptions = null)
: base(innerAgent)
{
this._jsonSerializerOptions = jsonSerializerOptions ?? AgentJsonUtilities.DefaultOptions;
this._sessionState = new ProviderSessionState<ToolApprovalState>(
_ => new ToolApprovalState(),
"toolApprovalState",
this._jsonSerializerOptions);
}
/// <inheritdoc />
protected override async Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
// Steps 12: Unwrap AlwaysApprove wrappers, process any queued approval requests.
var (state, callerMessages, nextQueuedItem) = this.PrepareInboundMessages(messages, session);
if (nextQueuedItem is not null)
{
// Queue still has items — return the next one to the caller for approval.
return new AgentResponse(new ChatMessage(ChatRole.Assistant, [nextQueuedItem]));
}
// 3. Call the inner agent in a loop. If the inner agent returns approval requests
// that are ALL auto-approved by standing rules, we immediately re-call with the
// collected approval responses injected. This avoids returning empty responses.
while (true)
{
// Inject any collected approval responses as a user message ahead of the caller's messages.
var processedMessages = this.InjectCollectedResponses(callerMessages, state, session);
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);
if (!allAutoApproved)
{
// Response has real content or an unapproved approval request — return to caller.
return response;
}
// All approval requests were auto-approved. Loop to re-invoke with them injected.
callerMessages = [];
}
}
/// <inheritdoc />
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// Steps 12: Unwrap AlwaysApprove wrappers, process any queued approval requests.
var (state, callerMessages, nextQueuedItem) = this.PrepareInboundMessages(messages, session);
if (nextQueuedItem is not null)
{
// Queue still has items — yield the next one to the caller for approval.
yield return new AgentResponseUpdate(ChatRole.Assistant, [nextQueuedItem]);
yield break;
}
// 3. Stream from the inner agent in a loop. If all approval requests from the stream
// are auto-approved by standing rules, we immediately re-stream with the collected
// approval responses injected. This avoids returning empty streams.
while (true)
{
// Inject any collected approval responses as a user message ahead of the caller's messages.
var processedMessages = this.InjectCollectedResponses(callerMessages, state, session);
// Stream from the inner agent. Non-approval content is yielded immediately.
// Approval requests are collected (not yielded) so we can classify the full batch.
List<ToolApprovalRequestContent> streamedApprovalRequests = [];
await foreach (var update in this.InnerAgent.RunStreamingAsync(processedMessages, session, options, cancellationToken).ConfigureAwait(false))
{
// Fast path: no approval content in this update — yield as-is.
bool hasApprovalRequests = false;
foreach (var content in update.Contents)
{
if (content is ToolApprovalRequestContent)
{
hasApprovalRequests = true;
break;
}
}
if (!hasApprovalRequests)
{
yield return update;
continue;
}
// Split the update: collect approval requests, keep other content.
var filteredContents = new List<AIContent>();
foreach (var content in update.Contents)
{
if (content is ToolApprovalRequestContent tarc)
{
streamedApprovalRequests.Add(tarc);
}
else
{
filteredContents.Add(content);
}
}
// Yield the non-approval portion of the update (if any) as a cloned update.
if (filteredContents.Count > 0)
{
yield return new AgentResponseUpdate(update.Role, filteredContents)
{
AuthorName = update.AuthorName,
AdditionalProperties = update.AdditionalProperties,
AgentId = update.AgentId,
ResponseId = update.ResponseId,
MessageId = update.MessageId,
CreatedAt = update.CreatedAt,
ContinuationToken = update.ContinuationToken,
FinishReason = update.FinishReason,
RawRepresentation = update.RawRepresentation,
};
}
}
// If the stream contained no approval requests, we're done.
if (streamedApprovalRequests.Count == 0)
{
yield break;
}
// 4. Classify the collected approval requests against standing rules.
List<ToolApprovalRequestContent> unapproved = [];
foreach (var tarc in streamedApprovalRequests)
{
if (MatchesRule(tarc, state.Rules, this._jsonSerializerOptions))
{
state.CollectedApprovalResponses.Add(
tarc.CreateResponse(approved: true, reason: "Auto-approved by standing rule"));
}
else
{
unapproved.Add(tarc);
}
}
// If all were auto-approved, loop to re-invoke the inner agent with them injected.
if (unapproved.Count == 0)
{
callerMessages = [];
continue;
}
// 5. Queue excess unapproved requests and yield only the first to the caller.
if (unapproved.Count > 1)
{
state.QueuedApprovalRequests.AddRange(unapproved.GetRange(1, unapproved.Count - 1));
}
this._sessionState.SaveState(session, state);
yield return new AgentResponseUpdate(ChatRole.Assistant, [unapproved[0]]);
yield break;
}
}
/// <summary>
/// Extracts <see cref="ToolApprovalResponseContent"/> instances from the caller's messages
/// and collects them into <see cref="ToolApprovalState.CollectedApprovalResponses"/>.
/// Extracted responses are removed from the messages in-place.
/// </summary>
private static void CollectApprovalResponsesFromMessages(
List<ChatMessage> messages,
ToolApprovalState state)
{
// Walk messages in reverse so we can safely remove by index.
for (int i = messages.Count - 1; i >= 0; i--)
{
var message = messages[i];
// Quick check: does this message contain any approval responses?
bool hasApprovalResponse = false;
foreach (var content in message.Contents)
{
if (content is ToolApprovalResponseContent)
{
hasApprovalResponse = true;
break;
}
}
if (!hasApprovalResponse)
{
continue;
}
// Separate approval responses (→ state) from other content (→ keep in message).
var remaining = new List<AIContent>(message.Contents.Count);
foreach (var content in message.Contents)
{
if (content is ToolApprovalResponseContent response)
{
state.CollectedApprovalResponses.Add(response);
}
else
{
remaining.Add(content);
}
}
// Remove the message entirely if it only contained approval responses,
// otherwise replace it with a clone that has the approval responses stripped.
if (remaining.Count == 0)
{
messages.RemoveAt(i);
}
else
{
var cloned = message.Clone();
cloned.Contents = remaining;
messages[i] = cloned;
}
}
}
/// <summary>
/// Re-evaluates queued approval requests against current rules and auto-approves any that now match.
/// </summary>
private void DrainAutoApprovableFromQueue(ToolApprovalState state)
{
for (int i = state.QueuedApprovalRequests.Count - 1; i >= 0; i--)
{
if (MatchesRule(state.QueuedApprovalRequests[i], state.Rules, this._jsonSerializerOptions))
{
state.CollectedApprovalResponses.Add(
state.QueuedApprovalRequests[i].CreateResponse(approved: true, reason: "Auto-approved by standing rule"));
state.QueuedApprovalRequests.RemoveAt(i);
}
}
}
/// <summary>
/// Performs the common inbound processing shared by both the streaming and non-streaming paths:
/// <list type="number">
/// <item>Unwraps <see cref="AlwaysApproveToolApprovalResponseContent"/> wrappers, extracting standing rules.</item>
/// <item>If there are queued approval requests from a previous batch, collects the caller's responses,
/// drains any items now resolvable by new rules, and dequeues the next item if any remain.</item>
/// </list>
/// </summary>
/// <returns>
/// A tuple of (state, processed caller messages, next queued item or <see langword="null"/> if the queue is resolved).
/// When the returned item is non-null, the caller should return/yield it without calling the inner agent.
/// </returns>
private (ToolApprovalState State, List<ChatMessage> CallerMessages, ToolApprovalRequestContent? NextQueuedItem)
PrepareInboundMessages(IEnumerable<ChatMessage> messages, AgentSession? session)
{
var state = this._sessionState.GetOrInitializeState(session);
// 1. Unwrap any AlwaysApprove wrappers in the caller's messages.
// This extracts standing approval rules into state and replaces wrappers with plain responses.
var callerMessages = UnwrapAlwaysApproveResponses(messages, state, this._jsonSerializerOptions);
// 2. If there are queued approval requests from a previous batch, handle them
// before calling the inner agent.
if (state.QueuedApprovalRequests.Count > 0)
{
// Collect the caller's approval/denial responses for the previously dequeued item
// and store them in state for the next downstream call.
CollectApprovalResponsesFromMessages(callerMessages, state);
// 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);
if (state.QueuedApprovalRequests.Count > 0)
{
// More items remain — dequeue the next one for the caller.
var next = state.QueuedApprovalRequests[0];
state.QueuedApprovalRequests.RemoveAt(0);
this._sessionState.SaveState(session, state);
return (state, callerMessages, next);
}
// Queue fully resolved — caller should proceed to call the inner agent.
}
return (state, callerMessages, null);
}
/// <summary>
/// Injects any collected approval responses as user messages before the caller's messages,
/// then clears the collected responses.
/// </summary>
private List<ChatMessage> InjectCollectedResponses(
List<ChatMessage> callerMessages,
ToolApprovalState state,
AgentSession? session)
{
if (state.CollectedApprovalResponses.Count > 0)
{
List<ChatMessage> result = [new ChatMessage(ChatRole.User, [.. state.CollectedApprovalResponses])];
result.AddRange(callerMessages);
state.CollectedApprovalResponses.Clear();
this._sessionState.SaveState(session, state);
return result;
}
return callerMessages;
}
/// <summary>
/// Processes outbound approval requests from non-streaming response messages.
/// Auto-approvable requests are collected as responses, and if multiple unapproved requests
/// remain, only the first is kept in the response while the rest are queued for subsequent calls.
/// </summary>
/// <returns>
/// <see langword="true"/> if all TARc items were auto-approved (caller should re-invoke the inner agent);
/// <see langword="false"/> otherwise.
/// </returns>
private bool ProcessAndQueueOutboundApprovalRequests(
IList<ChatMessage> 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<ToolApprovalRequestContent>();
var unapproved = new List<ToolApprovalRequestContent>();
foreach (var message in responseMessages)
{
foreach (var content in message.Contents)
{
if (content is ToolApprovalRequestContent tarc)
{
if (MatchesRule(tarc, state.Rules, this._jsonSerializerOptions))
{
autoApproved.Add(tarc);
}
else
{
unapproved.Add(tarc);
}
}
}
}
// Nothing to process: no auto-approved items and at most one unapproved (no queueing needed).
if (autoApproved.Count == 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)
{
RemoveAllToolApprovalRequests(responseMessages);
this._sessionState.SaveState(session, state);
return true;
}
// 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<ToolApprovalRequestContent>(autoApproved);
if (unapproved.Count > 1)
{
for (int i = 1; i < unapproved.Count; i++)
{
toRemove.Add(unapproved[i]);
state.QueuedApprovalRequests.Add(unapproved[i]);
}
}
// Walk messages in reverse and strip marked items.
for (int i = responseMessages.Count - 1; i >= 0; i--)
{
var message = responseMessages[i];
// Quick check: does this message contain any items to remove?
bool hasRemovable = false;
foreach (var content in message.Contents)
{
if (content is ToolApprovalRequestContent tarc && toRemove.Contains(tarc))
{
hasRemovable = true;
break;
}
}
if (!hasRemovable)
{
continue;
}
// Filter out the marked items, keeping everything else.
var remaining = new List<AIContent>(message.Contents.Count);
foreach (var content in message.Contents)
{
if (content is ToolApprovalRequestContent tarc && toRemove.Contains(tarc))
{
continue;
}
remaining.Add(content);
}
// Remove the message entirely if it's now empty, otherwise replace with filtered clone.
if (remaining.Count == 0)
{
responseMessages.RemoveAt(i);
}
else
{
var clonedMessage = message.Clone();
clonedMessage.Contents = remaining;
responseMessages[i] = clonedMessage;
}
}
this._sessionState.SaveState(session, state);
return false;
}
/// <summary>
/// Removes all <see cref="ToolApprovalRequestContent"/> items from response messages.
/// </summary>
private static void RemoveAllToolApprovalRequests(IList<ChatMessage> responseMessages)
{
// Walk messages in reverse so we can safely remove by index.
for (int i = responseMessages.Count - 1; i >= 0; i--)
{
var message = responseMessages[i];
// Quick check: does this message contain any approval requests?
bool hasTarc = false;
foreach (var content in message.Contents)
{
if (content is ToolApprovalRequestContent)
{
hasTarc = true;
break;
}
}
if (!hasTarc)
{
continue;
}
// Keep only non-approval content.
var remaining = new List<AIContent>(message.Contents.Count);
foreach (var content in message.Contents)
{
if (content is not ToolApprovalRequestContent)
{
remaining.Add(content);
}
}
// Remove the message entirely if it's now empty, otherwise replace with filtered clone.
if (remaining.Count == 0)
{
responseMessages.RemoveAt(i);
}
else
{
var clonedMessage = message.Clone();
clonedMessage.Contents = remaining;
responseMessages[i] = clonedMessage;
}
}
}
/// <summary>
/// Scans input messages for <see cref="AlwaysApproveToolApprovalResponseContent"/> instances,
/// extracts standing approval rules, and replaces them in-place with the unwrapped inner
/// <see cref="ToolApprovalResponseContent"/>, preserving content ordering.
/// </summary>
private static List<ChatMessage> UnwrapAlwaysApproveResponses(
IEnumerable<ChatMessage> messages,
ToolApprovalState state,
JsonSerializerOptions jsonSerializerOptions)
{
var messageList = messages as IList<ChatMessage> ?? new List<ChatMessage>(messages);
var result = new List<ChatMessage>(messageList.Count);
bool anyModified = false;
foreach (var message in messageList)
{
// Quick check: does this message contain any AlwaysApprove wrappers?
bool hasAlwaysApprove = false;
foreach (var content in message.Contents)
{
if (content is AlwaysApproveToolApprovalResponseContent)
{
hasAlwaysApprove = true;
break;
}
}
if (!hasAlwaysApprove)
{
result.Add(message);
continue;
}
// Walk content items, replacing each AlwaysApprove wrapper with its inner response
// while extracting the standing approval rule into state.
var newContents = new List<AIContent>(message.Contents.Count);
foreach (var content in message.Contents)
{
if (content is AlwaysApproveToolApprovalResponseContent alwaysApprove)
{
// Extract and store the standing approval rule.
if (alwaysApprove.InnerResponse.ToolCall is FunctionCallContent toolCall)
{
if (alwaysApprove.AlwaysApproveTool)
{
AddRuleIfNotExists(state, new ToolApprovalRule { ToolName = toolCall.Name });
}
else if (alwaysApprove.AlwaysApproveToolWithArguments)
{
AddRuleIfNotExists(state, new ToolApprovalRule
{
ToolName = toolCall.Name,
Arguments = SerializeArguments(toolCall.Arguments, jsonSerializerOptions),
});
}
}
// Replace the wrapper with the unwrapped inner response, preserving position.
newContents.Add(alwaysApprove.InnerResponse);
}
else
{
newContents.Add(content);
}
}
// Clone the original message so all metadata is preserved, then replace contents.
var clonedMessage = message.Clone();
clonedMessage.Contents = newContents;
result.Add(clonedMessage);
anyModified = true;
}
// Avoid allocating a new list if nothing was modified.
return anyModified ? result : (messageList as List<ChatMessage> ?? messageList.ToList());
}
/// <summary>
/// Determines whether a tool approval request matches any of the stored rules.
/// </summary>
internal static bool MatchesRule(
ToolApprovalRequestContent request,
IReadOnlyList<ToolApprovalRule> rules,
JsonSerializerOptions jsonSerializerOptions)
{
if (request.ToolCall is not FunctionCallContent functionCall)
{
return false;
}
foreach (var rule in rules)
{
if (!string.Equals(rule.ToolName, functionCall.Name, StringComparison.Ordinal))
{
continue;
}
// Tool-level rule: matches any arguments
if (rule.Arguments is null)
{
return true;
}
// Tool+arguments rule: exact match on all argument values
if (ArgumentsMatch(rule.Arguments, functionCall.Arguments, jsonSerializerOptions))
{
return true;
}
}
return false;
}
/// <summary>
/// Compares stored rule arguments against actual function call arguments for an exact match.
/// </summary>
private static bool ArgumentsMatch(IDictionary<string, string> ruleArguments, IDictionary<string, object?>? callArguments, JsonSerializerOptions jsonSerializerOptions)
{
if (callArguments is null)
{
return ruleArguments.Count == 0;
}
if (ruleArguments.Count != callArguments.Count)
{
return false;
}
foreach (var kvp in ruleArguments)
{
if (!callArguments.TryGetValue(kvp.Key, out var callValue))
{
return false;
}
var serializedCallValue = SerializeArgumentValue(callValue, jsonSerializerOptions);
if (!string.Equals(kvp.Value, serializedCallValue, StringComparison.Ordinal))
{
return false;
}
}
return true;
}
/// <summary>
/// Serializes function call arguments to a string dictionary for storage and comparison.
/// </summary>
private static Dictionary<string, string>? SerializeArguments(IDictionary<string, object?>? arguments, JsonSerializerOptions jsonSerializerOptions)
{
if (arguments is null || arguments.Count == 0)
{
return null;
}
var serialized = new Dictionary<string, string>(arguments.Count, StringComparer.Ordinal);
foreach (var kvp in arguments)
{
serialized[kvp.Key] = SerializeArgumentValue(kvp.Value, jsonSerializerOptions);
}
return serialized;
}
/// <summary>
/// Serializes a single argument value to its JSON string representation.
/// </summary>
private static string SerializeArgumentValue(object? value, JsonSerializerOptions jsonSerializerOptions)
{
if (value is null)
{
return "null";
}
if (value is JsonElement jsonElement)
{
return jsonElement.GetRawText();
}
return JsonSerializer.Serialize(value, jsonSerializerOptions.GetTypeInfo(value.GetType()));
}
/// <summary>
/// Adds a rule to the state if an equivalent rule does not already exist.
/// </summary>
private static void AddRuleIfNotExists(ToolApprovalState state, ToolApprovalRule newRule)
{
foreach (var existingRule in state.Rules)
{
if (!string.Equals(existingRule.ToolName, newRule.ToolName, StringComparison.Ordinal))
{
continue;
}
if (existingRule.Arguments is null && newRule.Arguments is null)
{
return; // Duplicate tool-level rule
}
if (existingRule.Arguments is not null && newRule.Arguments is not null &&
ArgumentDictionariesEqual(existingRule.Arguments, newRule.Arguments))
{
return; // Duplicate tool+args rule
}
}
state.Rules.Add(newRule);
}
/// <summary>
/// Compares two string dictionaries for equality.
/// </summary>
private static bool ArgumentDictionariesEqual(IDictionary<string, string> a, IDictionary<string, string> b)
{
if (a.Count != b.Count)
{
return false;
}
foreach (var kvp in a)
{
if (!b.TryGetValue(kvp.Key, out var bValue) || !string.Equals(kvp.Value, bValue, StringComparison.Ordinal))
{
return false;
}
}
return true;
}
}
@@ -0,0 +1,37 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides extension methods for adding tool approval middleware to <see cref="AIAgentBuilder"/> instances.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public static class ToolApprovalAgentBuilderExtensions
{
/// <summary>
/// Adds tool approval middleware to the agent pipeline, enabling "don't ask again" approval behavior.
/// </summary>
/// <param name="builder">The <see cref="AIAgentBuilder"/> to which tool approval support will be added.</param>
/// <param name="jsonSerializerOptions">
/// Optional <see cref="JsonSerializerOptions"/> used for serializing argument values when storing rules
/// and for persisting state. When <see langword="null"/>, <see cref="AgentJsonUtilities.DefaultOptions"/> is used.
/// </param>
/// <returns>The <see cref="AIAgentBuilder"/> with tool approval middleware added, enabling method chaining.</returns>
/// <exception cref="System.ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
/// <remarks>
/// <para>
/// The <see cref="ToolApprovalAgent"/> middleware intercepts tool approval flows between the caller and the inner agent.
/// When a caller responds with an <see cref="AlwaysApproveToolApprovalResponseContent"/>, the middleware records a standing
/// approval rule so that future matching tool calls are auto-approved without user interaction.
/// </para>
/// </remarks>
public static AIAgentBuilder UseToolApproval(
this AIAgentBuilder builder,
JsonSerializerOptions? jsonSerializerOptions = null)
=> Throw.IfNull(builder).Use(innerAgent => new ToolApprovalAgent(innerAgent, jsonSerializerOptions));
}
@@ -0,0 +1,65 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides extension methods on <see cref="ToolApprovalRequestContent"/> for creating
/// <see cref="AlwaysApproveToolApprovalResponseContent"/> instances that instruct the
/// <see cref="ToolApprovalAgent"/> middleware to record standing approval rules.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public static class ToolApprovalRequestContentExtensions
{
/// <summary>
/// Creates an approved <see cref="AlwaysApproveToolApprovalResponseContent"/> that also
/// instructs the middleware to always approve future calls to the same tool,
/// regardless of the arguments provided.
/// </summary>
/// <param name="request">The tool approval request to respond to.</param>
/// <param name="reason">An optional reason for the approval.</param>
/// <returns>
/// An <see cref="AlwaysApproveToolApprovalResponseContent"/> wrapping an approved
/// <see cref="ToolApprovalResponseContent"/> with the <see cref="AlwaysApproveToolApprovalResponseContent.AlwaysApproveTool"/>
/// flag set to <see langword="true"/>.
/// </returns>
public static AlwaysApproveToolApprovalResponseContent CreateAlwaysApproveToolResponse(
this ToolApprovalRequestContent request,
string? reason = null)
{
_ = Throw.IfNull(request);
return new AlwaysApproveToolApprovalResponseContent(
request.CreateResponse(approved: true, reason),
alwaysApproveTool: true,
alwaysApproveToolWithArguments: false);
}
/// <summary>
/// Creates an approved <see cref="AlwaysApproveToolApprovalResponseContent"/> that also
/// instructs the middleware to always approve future calls to the same tool
/// with the exact same arguments.
/// </summary>
/// <param name="request">The tool approval request to respond to.</param>
/// <param name="reason">An optional reason for the approval.</param>
/// <returns>
/// An <see cref="AlwaysApproveToolApprovalResponseContent"/> wrapping an approved
/// <see cref="ToolApprovalResponseContent"/> with the <see cref="AlwaysApproveToolApprovalResponseContent.AlwaysApproveToolWithArguments"/>
/// flag set to <see langword="true"/>.
/// </returns>
public static AlwaysApproveToolApprovalResponseContent CreateAlwaysApproveToolWithArgumentsResponse(
this ToolApprovalRequestContent request,
string? reason = null)
{
_ = Throw.IfNull(request);
return new AlwaysApproveToolApprovalResponseContent(
request.CreateResponse(approved: true, reason),
alwaysApproveTool: false,
alwaysApproveToolWithArguments: true);
}
}
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents a standing approval rule for automatically approving tool calls
/// without requiring explicit user approval each time.
/// </summary>
/// <remarks>
/// <para>
/// A rule can match tool calls in two ways:
/// <list type="bullet">
/// <item><b>Tool-level</b>: When <see cref="Arguments"/> is <see langword="null"/>,
/// all calls to the tool identified by <see cref="ToolName"/> are auto-approved.</item>
/// <item><b>Tool+arguments</b>: When <see cref="Arguments"/> is non-null,
/// only calls to the specified tool with exactly matching argument values are auto-approved.</item>
/// </list>
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed class ToolApprovalRule
{
/// <summary>
/// Gets or sets the name of the tool function that this rule applies to.
/// </summary>
[JsonPropertyName("toolName")]
public string ToolName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the specific argument values that must match for this rule to apply.
/// When <see langword="null"/>, the rule applies to all invocations of the tool
/// regardless of arguments.
/// </summary>
/// <remarks>
/// Argument values are stored as their JSON-serialized string representations
/// for reliable comparison.
/// </remarks>
[JsonPropertyName("arguments")]
public IDictionary<string, string>? Arguments { get; set; }
}
@@ -0,0 +1,53 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents the persisted state of standing tool approval rules,
/// stored in the session's <see cref="AgentSessionStateBag"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed class ToolApprovalState
{
/// <summary>
/// Gets or sets the list of standing approval rules.
/// </summary>
[JsonPropertyName("rules")]
public List<ToolApprovalRule> Rules { get; set; } = new();
/// <summary>
/// Gets or sets the list of collected approval responses (both auto-approved and user-approved)
/// that are pending injection into the next inbound call to the inner agent.
/// </summary>
/// <remarks>
/// <para>
/// Responses are collected during a queue cycle: when the inner agent returns multiple tool approval
/// requests, auto-approved ones and user-approved ones are accumulated here. Once all queued requests
/// are resolved, the collected responses are injected alongside the caller's messages so the inner
/// agent receives all tool responses together.
/// </para>
/// </remarks>
[JsonPropertyName("collectedApprovalResponses")]
public List<ToolApprovalResponseContent> CollectedApprovalResponses { get; set; } = new();
/// <summary>
/// Gets or sets the list of queued tool approval requests that have not yet been
/// presented to the caller.
/// </summary>
/// <remarks>
/// <para>
/// When the inner agent returns multiple unapproved tool approval requests, only the first
/// is returned to the caller. The remaining requests are stored here and presented one at a
/// time on subsequent calls, allowing the caller's "always approve" rules to take effect on
/// later items in the same batch.
/// </para>
/// </remarks>
[JsonPropertyName("queuedApprovalRequests")]
public List<ToolApprovalRequestContent> QueuedApprovalRequests { get; set; } = new();
}
@@ -0,0 +1,288 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AlwaysApproveToolApprovalResponseContent"/> class
/// and <see cref="ToolApprovalRequestContentExtensions"/> extension methods.
/// </summary>
public class AlwaysApproveToolApprovalResponseContentTests
{
#region CreateAlwaysApproveToolResponse
/// <summary>
/// Verify that CreateAlwaysApproveToolResponse sets AlwaysApproveTool to true.
/// </summary>
[Fact]
public void CreateAlwaysApproveToolResponse_AlwaysApproveTool_IsTrue()
{
// Arrange
var request = CreateRequest("MyTool");
// Act
var result = request.CreateAlwaysApproveToolResponse();
// Assert
Assert.True(result.AlwaysApproveTool);
}
/// <summary>
/// Verify that CreateAlwaysApproveToolResponse sets AlwaysApproveToolWithArguments to false.
/// </summary>
[Fact]
public void CreateAlwaysApproveToolResponse_AlwaysApproveToolWithArguments_IsFalse()
{
// Arrange
var request = CreateRequest("MyTool");
// Act
var result = request.CreateAlwaysApproveToolResponse();
// Assert
Assert.False(result.AlwaysApproveToolWithArguments);
}
/// <summary>
/// Verify that CreateAlwaysApproveToolResponse creates an approved inner response.
/// </summary>
[Fact]
public void CreateAlwaysApproveToolResponse_InnerResponse_IsApproved()
{
// Arrange
var request = CreateRequest("MyTool");
// Act
var result = request.CreateAlwaysApproveToolResponse();
// Assert
Assert.True(result.InnerResponse.Approved);
}
/// <summary>
/// Verify that CreateAlwaysApproveToolResponse forwards the reason.
/// </summary>
[Fact]
public void CreateAlwaysApproveToolResponse_Reason_IsForwarded()
{
// Arrange
var request = CreateRequest("MyTool");
// Act
var result = request.CreateAlwaysApproveToolResponse("User trusts this tool");
// Assert
Assert.Equal("User trusts this tool", result.InnerResponse.Reason);
}
/// <summary>
/// Verify that CreateAlwaysApproveToolResponse preserves the request ID.
/// </summary>
[Fact]
public void CreateAlwaysApproveToolResponse_RequestId_IsPreserved()
{
// Arrange
var request = CreateRequest("MyTool", "custom-request-id");
// Act
var result = request.CreateAlwaysApproveToolResponse();
// Assert
Assert.Equal("custom-request-id", result.InnerResponse.RequestId);
}
/// <summary>
/// Verify that CreateAlwaysApproveToolResponse preserves the tool call.
/// </summary>
[Fact]
public void CreateAlwaysApproveToolResponse_ToolCall_IsPreserved()
{
// Arrange
var request = CreateRequest("MyTool");
// Act
var result = request.CreateAlwaysApproveToolResponse();
// Assert
var functionCall = Assert.IsType<FunctionCallContent>(result.InnerResponse.ToolCall);
Assert.Equal("MyTool", functionCall.Name);
}
/// <summary>
/// Verify that CreateAlwaysApproveToolResponse with null reason sets reason to null.
/// </summary>
[Fact]
public void CreateAlwaysApproveToolResponse_NullReason_ReasonIsNull()
{
// Arrange
var request = CreateRequest("MyTool");
// Act
var result = request.CreateAlwaysApproveToolResponse();
// Assert
Assert.Null(result.InnerResponse.Reason);
}
/// <summary>
/// Verify that CreateAlwaysApproveToolResponse throws on null request.
/// </summary>
[Fact]
public void CreateAlwaysApproveToolResponse_NullRequest_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>("request",
() => ((ToolApprovalRequestContent)null!).CreateAlwaysApproveToolResponse());
}
#endregion
#region CreateAlwaysApproveToolWithArgumentsResponse
/// <summary>
/// Verify that CreateAlwaysApproveToolWithArgumentsResponse sets AlwaysApproveToolWithArguments to true.
/// </summary>
[Fact]
public void CreateAlwaysApproveToolWithArgumentsResponse_AlwaysApproveToolWithArguments_IsTrue()
{
// Arrange
var request = CreateRequest("MyTool");
// Act
var result = request.CreateAlwaysApproveToolWithArgumentsResponse();
// Assert
Assert.True(result.AlwaysApproveToolWithArguments);
}
/// <summary>
/// Verify that CreateAlwaysApproveToolWithArgumentsResponse sets AlwaysApproveTool to false.
/// </summary>
[Fact]
public void CreateAlwaysApproveToolWithArgumentsResponse_AlwaysApproveTool_IsFalse()
{
// Arrange
var request = CreateRequest("MyTool");
// Act
var result = request.CreateAlwaysApproveToolWithArgumentsResponse();
// Assert
Assert.False(result.AlwaysApproveTool);
}
/// <summary>
/// Verify that CreateAlwaysApproveToolWithArgumentsResponse creates an approved inner response.
/// </summary>
[Fact]
public void CreateAlwaysApproveToolWithArgumentsResponse_InnerResponse_IsApproved()
{
// Arrange
var request = CreateRequest("MyTool");
// Act
var result = request.CreateAlwaysApproveToolWithArgumentsResponse();
// Assert
Assert.True(result.InnerResponse.Approved);
}
/// <summary>
/// Verify that CreateAlwaysApproveToolWithArgumentsResponse forwards the reason.
/// </summary>
[Fact]
public void CreateAlwaysApproveToolWithArgumentsResponse_Reason_IsForwarded()
{
// Arrange
var request = CreateRequest("MyTool");
// Act
var result = request.CreateAlwaysApproveToolWithArgumentsResponse("Specific approval");
// Assert
Assert.Equal("Specific approval", result.InnerResponse.Reason);
}
/// <summary>
/// Verify that CreateAlwaysApproveToolWithArgumentsResponse throws on null request.
/// </summary>
[Fact]
public void CreateAlwaysApproveToolWithArgumentsResponse_NullRequest_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>("request",
() => ((ToolApprovalRequestContent)null!).CreateAlwaysApproveToolWithArgumentsResponse());
}
#endregion
#region AlwaysApproveToolApprovalResponseContent Properties
/// <summary>
/// Verify that the content is an AIContent subclass.
/// </summary>
[Fact]
public void Content_IsAIContentSubclass()
{
// Arrange
var request = CreateRequest("MyTool");
// Act
var result = request.CreateAlwaysApproveToolResponse();
// Assert
Assert.IsAssignableFrom<AIContent>(result);
}
/// <summary>
/// Verify that InnerResponse preserves tool call arguments.
/// </summary>
[Fact]
public void InnerResponse_PreservesArguments()
{
// Arrange
var args = new Dictionary<string, object?> { ["path"] = "test.txt", ["count"] = 5 };
var request = new ToolApprovalRequestContent("req1",
new FunctionCallContent("call1", "ReadFile", args));
// Act
var result = request.CreateAlwaysApproveToolWithArgumentsResponse();
// Assert
var functionCall = Assert.IsType<FunctionCallContent>(result.InnerResponse.ToolCall);
Assert.Equal(2, functionCall.Arguments!.Count);
Assert.Equal("test.txt", functionCall.Arguments["path"]);
}
/// <summary>
/// Verify that both factory methods produce distinct instances from the same request.
/// </summary>
[Fact]
public void FactoryMethods_ProduceDistinctInstances()
{
// Arrange
var request = CreateRequest("MyTool");
// Act
var toolLevel = request.CreateAlwaysApproveToolResponse();
var argsLevel = request.CreateAlwaysApproveToolWithArgumentsResponse();
// Assert
Assert.NotSame(toolLevel, argsLevel);
Assert.NotSame(toolLevel.InnerResponse, argsLevel.InnerResponse);
}
#endregion
#region Helpers
private static ToolApprovalRequestContent CreateRequest(string toolName, string requestId = "req1")
{
return new ToolApprovalRequestContent(requestId, new FunctionCallContent("call1", toolName));
}
#endregion
}
@@ -0,0 +1,75 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="ToolApprovalAgentBuilderExtensions"/> class.
/// </summary>
public class ToolApprovalAgentBuilderExtensionsTests
{
/// <summary>
/// Verify that UseToolApproval throws ArgumentNullException when builder is null.
/// </summary>
[Fact]
public void UseToolApproval_WithNullBuilder_ThrowsArgumentNullException()
{
// Act & Assert
Assert.Throws<ArgumentNullException>("builder", () => ((AIAgentBuilder)null!).UseToolApproval());
}
/// <summary>
/// Verify that UseToolApproval returns a ToolApprovalAgent.
/// </summary>
[Fact]
public void UseToolApproval_WithValidBuilder_ReturnsToolApprovalAgent()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act
var result = builder.UseToolApproval().Build();
// Assert
Assert.IsType<ToolApprovalAgent>(result);
}
/// <summary>
/// Verify that UseToolApproval returns the same builder instance for chaining.
/// </summary>
[Fact]
public void UseToolApproval_ReturnsBuilderForChaining()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act
var result = builder.UseToolApproval();
// Assert
Assert.Same(builder, result);
}
/// <summary>
/// Verify that UseToolApproval with custom JsonSerializerOptions works correctly.
/// </summary>
[Fact]
public void UseToolApproval_WithCustomJsonSerializerOptions_ReturnsToolApprovalAgent()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
var options = new JsonSerializerOptions();
// Act
var result = builder.UseToolApproval(jsonSerializerOptions: options).Build();
// Assert
Assert.IsType<ToolApprovalAgent>(result);
}
}
@@ -0,0 +1,154 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="ToolApprovalRule"/> class.
/// </summary>
public class ToolApprovalRuleTests
{
#region Construction and Defaults
/// <summary>
/// Verify that a new rule has the expected default values.
/// </summary>
[Fact]
public void NewRule_HasDefaultValues()
{
// Act
var rule = new ToolApprovalRule();
// Assert
Assert.Equal(string.Empty, rule.ToolName);
Assert.Null(rule.Arguments);
}
/// <summary>
/// Verify that ToolName can be set.
/// </summary>
[Fact]
public void ToolName_CanBeSet()
{
// Arrange & Act
var rule = new ToolApprovalRule { ToolName = "ReadFile" };
// Assert
Assert.Equal("ReadFile", rule.ToolName);
}
/// <summary>
/// Verify that Arguments can be set.
/// </summary>
[Fact]
public void Arguments_CanBeSet()
{
// Arrange & Act
var args = new Dictionary<string, string> { ["path"] = "test.txt" };
var rule = new ToolApprovalRule { ToolName = "ReadFile", Arguments = args };
// Assert
Assert.NotNull(rule.Arguments);
Assert.Equal("test.txt", rule.Arguments["path"]);
}
#endregion
#region JSON Serialization
/// <summary>
/// Verify that a tool-level rule round-trips through JSON serialization.
/// </summary>
[Fact]
public void Serialize_ToolLevelRule_RoundTrips()
{
// Arrange
var rule = new ToolApprovalRule { ToolName = "MyTool" };
// Act
var json = JsonSerializer.Serialize(rule, AgentJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<ToolApprovalRule>(json, AgentJsonUtilities.DefaultOptions);
// Assert
Assert.NotNull(deserialized);
Assert.Equal("MyTool", deserialized!.ToolName);
Assert.Null(deserialized.Arguments);
}
/// <summary>
/// Verify that a tool+arguments rule round-trips through JSON serialization.
/// </summary>
[Fact]
public void Serialize_ToolWithArgsRule_RoundTrips()
{
// Arrange
var rule = new ToolApprovalRule
{
ToolName = "ReadFile",
Arguments = new Dictionary<string, string> { ["path"] = "test.txt", ["encoding"] = "utf-8" },
};
// Act
var json = JsonSerializer.Serialize(rule, AgentJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<ToolApprovalRule>(json, AgentJsonUtilities.DefaultOptions);
// Assert
Assert.NotNull(deserialized);
Assert.Equal("ReadFile", deserialized!.ToolName);
Assert.NotNull(deserialized.Arguments);
Assert.Equal(2, deserialized.Arguments!.Count);
Assert.Equal("test.txt", deserialized.Arguments["path"]);
Assert.Equal("utf-8", deserialized.Arguments["encoding"]);
}
/// <summary>
/// Verify that JSON property names are correctly applied.
/// </summary>
[Fact]
public void Serialize_UsesJsonPropertyNames()
{
// Arrange
var rule = new ToolApprovalRule
{
ToolName = "MyTool",
Arguments = new Dictionary<string, string> { ["key"] = "value" },
};
// Act
var json = JsonSerializer.Serialize(rule, AgentJsonUtilities.DefaultOptions);
// Assert
Assert.Contains("\"toolName\"", json);
Assert.Contains("\"arguments\"", json);
}
/// <summary>
/// Verify that a list of rules round-trips through JSON serialization.
/// </summary>
[Fact]
public void Serialize_RuleList_RoundTrips()
{
// Arrange
var rules = new List<ToolApprovalRule>
{
new() { ToolName = "ToolA" },
new() { ToolName = "ToolB", Arguments = new Dictionary<string, string> { ["x"] = "1" } },
};
// Act
var json = JsonSerializer.Serialize(rules, AgentJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<List<ToolApprovalRule>>(json, AgentJsonUtilities.DefaultOptions);
// Assert
Assert.NotNull(deserialized);
Assert.Equal(2, deserialized!.Count);
Assert.Equal("ToolA", deserialized[0].ToolName);
Assert.Null(deserialized[0].Arguments);
Assert.Equal("ToolB", deserialized[1].ToolName);
Assert.NotNull(deserialized[1].Arguments);
}
#endregion
}