diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs index c47ed3cf25..9e46751001 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs @@ -61,6 +61,21 @@ public static async Task RunAgentAsync(AIAgent agent, string title, string userP 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> StreamAndCollectApprovalsAsync(IAsyncEnumerable updates, AgentModeProvider? modeProvider, AgentSession session, int? maxContextWindowTokens, int? maxOutputTokens) + { + var approvalRequests = new List(); string mode = modeProvider?.GetMode(session) ?? "unknown"; System.Console.ForegroundColor = GetModeColor(mode); System.Console.Write($"\n[{mode}] Agent: "); @@ -72,7 +87,7 @@ private static async Task StreamAgentResponseAsync(AIAgent agent, AgentSession s 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 @@ private static async Task StreamAgentResponseAsync(AIAgent agent, AgentSession s 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 @@ private static async Task StreamAgentResponseAsync(AIAgent agent, AgentSession s 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 @@ private static async Task StreamAgentResponseAsync(AIAgent agent, AgentSession s System.Console.ResetColor(); System.Console.WriteLine(); System.Console.WriteLine(); + + return approvalRequests; + } + + /// + /// Prompts the user for approval of each tool approval request. + /// Returns a list of messages to send back to the agent, or if there are no requests. + /// + private static List? PromptForApprovals(List approvalRequests) + { + if (approvalRequests.Count == 0) + { + return null; + } + + var responses = new List(); + 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) diff --git a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs index 9ac2e570bd..e1cc2b6ff7 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs @@ -29,31 +29,6 @@ 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 @@ Also save intermediate notes and findings as you go — this helps with long mul 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); diff --git a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/WebBrowsingTools.cs b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/WebBrowsingTool.cs similarity index 93% rename from dotnet/samples/02-agents/Harness/Harness_Step01_Research/WebBrowsingTools.cs rename to dotnet/samples/02-agents/Harness/Harness_Step01_Research/WebBrowsingTool.cs index 0c7e7d826e..8f35070bf6 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/WebBrowsingTools.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/WebBrowsingTool.cs @@ -2,25 +2,34 @@ using System.ComponentModel; using System.Net; +using System.Text.Json; using System.Text.RegularExpressions; using Microsoft.Extensions.AI; namespace SampleApp; /// -/// 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. /// -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); - /// - /// Gets the web browsing tools. - /// - public IList Tools { get; } = - [ - AIFunctionFactory.Create(DownloadUriAsync), - ]; + /// + public override string Name => this._inner.Name; + + /// + public override string Description => this._inner.Description; + + /// + public override JsonElement JsonSchema => this._inner.JsonSchema; + + /// + protected override ValueTask InvokeCoreAsync( + AIFunctionArguments arguments, + CancellationToken cancellationToken) => + this._inner.InvokeAsync(arguments, cancellationToken); [Description("Download the html from the given url as markdown")] private static async Task DownloadUriAsync( diff --git a/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs index cbcffcc679..a246424bef 100644 --- a/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs @@ -81,6 +81,11 @@ private static JsonSerializerOptions CreateDefaultOptions() // AgentModeProvider types [JsonSerializable(typeof(AgentModeState))] + // ToolApprovalAgent types + [JsonSerializable(typeof(ToolApprovalState))] + [JsonSerializable(typeof(ToolApprovalRule))] + [JsonSerializable(typeof(List), TypeInfoPropertyName = "ToolApprovalRuleList")] + // FileMemoryProvider types [JsonSerializable(typeof(FileMemoryState))] [JsonSerializable(typeof(FileSearchResult))] diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/PerServiceCallChatHistoryPersistingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/PerServiceCallChatHistoryPersistingChatClient.cs index 200baf7b94..ab62a38281 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/PerServiceCallChatHistoryPersistingChatClient.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/PerServiceCallChatHistoryPersistingChatClient.cs @@ -188,7 +188,7 @@ public override async IAsyncEnumerable GetStreamingResponseA 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 — diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/AlwaysApproveToolApprovalResponseContent.cs b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/AlwaysApproveToolApprovalResponseContent.cs new file mode 100644 index 0000000000..df9631713e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/AlwaysApproveToolApprovalResponseContent.cs @@ -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; + +/// +/// Wraps a with additional "always approve" settings, +/// enabling the middleware to record standing approval rules +/// so that future matching tool calls are auto-approved without user interaction. +/// +/// +/// +/// Instances of this class should not be created directly. Instead, use the extension methods +/// or +/// +/// on to create instances with the appropriate flags set. +/// +/// +/// The middleware will unwrap the to forward +/// to the inner agent, while extracting the approval settings to persist as +/// entries in the session state. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AlwaysApproveToolApprovalResponseContent : AIContent +{ + /// + /// Initializes a new instance of the class. + /// + /// The underlying approval response to forward to the agent. + /// + /// When , all future calls to this tool type will be auto-approved. + /// + /// + /// When , all future calls to this tool type with the same arguments will be auto-approved. + /// + internal AlwaysApproveToolApprovalResponseContent( + ToolApprovalResponseContent innerResponse, + bool alwaysApproveTool, + bool alwaysApproveToolWithArguments) + { + this.InnerResponse = Throw.IfNull(innerResponse); + this.AlwaysApproveTool = alwaysApproveTool; + this.AlwaysApproveToolWithArguments = alwaysApproveToolWithArguments; + } + + /// + /// Gets the underlying that will be forwarded to the inner agent. + /// + public ToolApprovalResponseContent InnerResponse { get; } + + /// + /// Gets a value indicating whether all future calls to the same tool should be auto-approved + /// regardless of the arguments provided. + /// + public bool AlwaysApproveTool { get; } + + /// + /// Gets a value indicating whether all future calls to the same tool with the exact same + /// arguments should be auto-approved. + /// + public bool AlwaysApproveToolWithArguments { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgent.cs b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgent.cs new file mode 100644 index 0000000000..8512f686ec --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgent.cs @@ -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; + +/// +/// A 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. +/// +/// +/// +/// This middleware intercepts the approval flow between the caller and the inner agent: +/// +/// +/// +/// Outbound (response to caller): When the inner agent surfaces items, +/// the middleware checks whether matching 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. +/// +/// +/// Inbound (caller to agent): When the caller sends an , +/// the middleware extracts the standing approval settings, records them as entries +/// in the session state, and forwards only the unwrapped to the inner agent. +/// Content ordering within each message is preserved. +/// +/// +/// +/// Approval rules are persisted in the and survive across agent runs within the same session. +/// Two categories of rules are supported: +/// +/// +/// Tool-level: Approve all calls to a specific tool, regardless of arguments. +/// Tool+arguments: Approve all calls to a specific tool with exactly matching arguments. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class ToolApprovalAgent : DelegatingAIAgent +{ + private readonly ProviderSessionState _sessionState; + private readonly JsonSerializerOptions _jsonSerializerOptions; + + /// + /// 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. + /// + /// is . + public ToolApprovalAgent(AIAgent innerAgent, JsonSerializerOptions? jsonSerializerOptions = null) + : base(innerAgent) + { + this._jsonSerializerOptions = jsonSerializerOptions ?? AgentJsonUtilities.DefaultOptions; + this._sessionState = new ProviderSessionState( + _ => new ToolApprovalState(), + "toolApprovalState", + this._jsonSerializerOptions); + } + + /// + protected override async Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + // Steps 1–2: 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 = []; + } + } + + /// + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // Steps 1–2: 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 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(); + 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 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; + } + } + + /// + /// Extracts instances from the caller's messages + /// and collects them into . + /// Extracted responses are removed from the messages in-place. + /// + private static void CollectApprovalResponsesFromMessages( + List 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(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; + } + } + } + + /// + /// Re-evaluates queued approval requests against current rules and auto-approves any that now match. + /// + 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); + } + } + } + + /// + /// Performs the common inbound processing shared by both the streaming and non-streaming paths: + /// + /// Unwraps wrappers, extracting standing rules. + /// 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. + /// + /// + /// + /// 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) + { + 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); + } + + /// + /// Injects any collected approval responses as user messages before the caller's messages, + /// then clears the collected responses. + /// + private List InjectCollectedResponses( + List callerMessages, + ToolApprovalState state, + AgentSession? session) + { + if (state.CollectedApprovalResponses.Count > 0) + { + List result = [new ChatMessage(ChatRole.User, [.. state.CollectedApprovalResponses])]; + result.AddRange(callerMessages); + + state.CollectedApprovalResponses.Clear(); + this._sessionState.SaveState(session, state); + + return result; + } + + return callerMessages; + } + + /// + /// 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. + /// + /// + /// if all TARc items were auto-approved (caller should re-invoke the inner agent); + /// otherwise. + /// + private bool ProcessAndQueueOutboundApprovalRequests( + 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(); + var unapproved = new List(); + + 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(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(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; + } + + /// + /// Removes all items from response messages. + /// + private static void RemoveAllToolApprovalRequests(IList 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(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; + } + } + } + + /// + /// Scans input messages for instances, + /// extracts standing approval rules, and replaces them in-place with the unwrapped inner + /// , preserving content ordering. + /// + private static List UnwrapAlwaysApproveResponses( + IEnumerable messages, + ToolApprovalState state, + JsonSerializerOptions jsonSerializerOptions) + { + var messageList = messages as IList ?? new List(messages); + var result = new List(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(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 ?? messageList.ToList()); + } + + /// + /// Determines whether a tool approval request matches any of the stored rules. + /// + internal static bool MatchesRule( + ToolApprovalRequestContent request, + IReadOnlyList 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; + } + + /// + /// Compares stored rule arguments against actual function call arguments for an exact match. + /// + private static bool ArgumentsMatch(IDictionary ruleArguments, IDictionary? 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; + } + + /// + /// Serializes function call arguments to a string dictionary for storage and comparison. + /// + private static Dictionary? SerializeArguments(IDictionary? arguments, JsonSerializerOptions jsonSerializerOptions) + { + if (arguments is null || arguments.Count == 0) + { + return null; + } + + var serialized = new Dictionary(arguments.Count, StringComparer.Ordinal); + foreach (var kvp in arguments) + { + serialized[kvp.Key] = SerializeArgumentValue(kvp.Value, jsonSerializerOptions); + } + + return serialized; + } + + /// + /// Serializes a single argument value to its JSON string representation. + /// + 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())); + } + + /// + /// Adds a rule to the state if an equivalent rule does not already exist. + /// + 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); + } + + /// + /// Compares two string dictionaries for equality. + /// + private static bool ArgumentDictionariesEqual(IDictionary a, IDictionary 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; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgentBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgentBuilderExtensions.cs new file mode 100644 index 0000000000..ec92bb8d6c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgentBuilderExtensions.cs @@ -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; + +/// +/// Provides extension methods for adding tool approval middleware to instances. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +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. + /// + /// The with tool approval middleware added, enabling method chaining. + /// is . + /// + /// + /// The middleware intercepts tool approval flows between the caller and the inner agent. + /// When a caller responds with an , the middleware records a standing + /// approval rule so that future matching tool calls are auto-approved without user interaction. + /// + /// + public static AIAgentBuilder UseToolApproval( + this AIAgentBuilder builder, + JsonSerializerOptions? jsonSerializerOptions = null) + => Throw.IfNull(builder).Use(innerAgent => new ToolApprovalAgent(innerAgent, jsonSerializerOptions)); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalRequestContentExtensions.cs b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalRequestContentExtensions.cs new file mode 100644 index 0000000000..9974962ed5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalRequestContentExtensions.cs @@ -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; + +/// +/// Provides extension methods on for creating +/// instances that instruct the +/// middleware to record standing approval rules. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public static class ToolApprovalRequestContentExtensions +{ + /// + /// Creates an approved that also + /// instructs the middleware to always approve future calls to the same tool, + /// regardless of the arguments provided. + /// + /// The tool approval request to respond to. + /// An optional reason for the approval. + /// + /// An wrapping an approved + /// with the + /// flag set to . + /// + 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); + } + + /// + /// Creates an approved that also + /// instructs the middleware to always approve future calls to the same tool + /// with the exact same arguments. + /// + /// The tool approval request to respond to. + /// An optional reason for the approval. + /// + /// An wrapping an approved + /// with the + /// flag set to . + /// + 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); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalRule.cs b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalRule.cs new file mode 100644 index 0000000000..e633d1e003 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalRule.cs @@ -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; + +/// +/// Represents a standing approval rule for automatically approving tool calls +/// without requiring explicit user approval each time. +/// +/// +/// +/// A rule can match tool calls in two ways: +/// +/// Tool-level: When is , +/// all calls to the tool identified by are auto-approved. +/// Tool+arguments: When is non-null, +/// only calls to the specified tool with exactly matching argument values are auto-approved. +/// +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal sealed class ToolApprovalRule +{ + /// + /// Gets or sets the name of the tool function that this rule applies to. + /// + [JsonPropertyName("toolName")] + public string ToolName { get; set; } = string.Empty; + + /// + /// Gets or sets the specific argument values that must match for this rule to apply. + /// When , the rule applies to all invocations of the tool + /// regardless of arguments. + /// + /// + /// Argument values are stored as their JSON-serialized string representations + /// for reliable comparison. + /// + [JsonPropertyName("arguments")] + public IDictionary? Arguments { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalState.cs b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalState.cs new file mode 100644 index 0000000000..b740dc03c7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalState.cs @@ -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; + +/// +/// Represents the persisted state of standing tool approval rules, +/// stored in the session's . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal sealed class ToolApprovalState +{ + /// + /// Gets or sets the list of standing approval rules. + /// + [JsonPropertyName("rules")] + public List Rules { get; set; } = new(); + + /// + /// 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. + /// + /// + /// + /// 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. + /// + /// + [JsonPropertyName("collectedApprovalResponses")] + public List CollectedApprovalResponses { get; set; } = new(); + + /// + /// Gets or sets the list of queued tool approval requests that have not yet been + /// presented to the caller. + /// + /// + /// + /// 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. + /// + /// + [JsonPropertyName("queuedApprovalRequests")] + public List QueuedApprovalRequests { get; set; } = new(); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/AlwaysApproveToolApprovalResponseContentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/AlwaysApproveToolApprovalResponseContentTests.cs new file mode 100644 index 0000000000..b70dadab31 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/AlwaysApproveToolApprovalResponseContentTests.cs @@ -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; + +/// +/// Unit tests for the class +/// and extension methods. +/// +public class AlwaysApproveToolApprovalResponseContentTests +{ + #region CreateAlwaysApproveToolResponse + + /// + /// Verify that CreateAlwaysApproveToolResponse sets AlwaysApproveTool to true. + /// + [Fact] + public void CreateAlwaysApproveToolResponse_AlwaysApproveTool_IsTrue() + { + // Arrange + var request = CreateRequest("MyTool"); + + // Act + var result = request.CreateAlwaysApproveToolResponse(); + + // Assert + Assert.True(result.AlwaysApproveTool); + } + + /// + /// Verify that CreateAlwaysApproveToolResponse sets AlwaysApproveToolWithArguments to false. + /// + [Fact] + public void CreateAlwaysApproveToolResponse_AlwaysApproveToolWithArguments_IsFalse() + { + // Arrange + var request = CreateRequest("MyTool"); + + // Act + var result = request.CreateAlwaysApproveToolResponse(); + + // Assert + Assert.False(result.AlwaysApproveToolWithArguments); + } + + /// + /// Verify that CreateAlwaysApproveToolResponse creates an approved inner response. + /// + [Fact] + public void CreateAlwaysApproveToolResponse_InnerResponse_IsApproved() + { + // Arrange + var request = CreateRequest("MyTool"); + + // Act + var result = request.CreateAlwaysApproveToolResponse(); + + // Assert + Assert.True(result.InnerResponse.Approved); + } + + /// + /// Verify that CreateAlwaysApproveToolResponse forwards the reason. + /// + [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); + } + + /// + /// Verify that CreateAlwaysApproveToolResponse preserves the request ID. + /// + [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); + } + + /// + /// Verify that CreateAlwaysApproveToolResponse preserves the tool call. + /// + [Fact] + public void CreateAlwaysApproveToolResponse_ToolCall_IsPreserved() + { + // Arrange + var request = CreateRequest("MyTool"); + + // Act + var result = request.CreateAlwaysApproveToolResponse(); + + // Assert + var functionCall = Assert.IsType(result.InnerResponse.ToolCall); + Assert.Equal("MyTool", functionCall.Name); + } + + /// + /// Verify that CreateAlwaysApproveToolResponse with null reason sets reason to null. + /// + [Fact] + public void CreateAlwaysApproveToolResponse_NullReason_ReasonIsNull() + { + // Arrange + var request = CreateRequest("MyTool"); + + // Act + var result = request.CreateAlwaysApproveToolResponse(); + + // Assert + Assert.Null(result.InnerResponse.Reason); + } + + /// + /// Verify that CreateAlwaysApproveToolResponse throws on null request. + /// + [Fact] + public void CreateAlwaysApproveToolResponse_NullRequest_Throws() + { + // Act & Assert + Assert.Throws("request", + () => ((ToolApprovalRequestContent)null!).CreateAlwaysApproveToolResponse()); + } + + #endregion + + #region CreateAlwaysApproveToolWithArgumentsResponse + + /// + /// Verify that CreateAlwaysApproveToolWithArgumentsResponse sets AlwaysApproveToolWithArguments to true. + /// + [Fact] + public void CreateAlwaysApproveToolWithArgumentsResponse_AlwaysApproveToolWithArguments_IsTrue() + { + // Arrange + var request = CreateRequest("MyTool"); + + // Act + var result = request.CreateAlwaysApproveToolWithArgumentsResponse(); + + // Assert + Assert.True(result.AlwaysApproveToolWithArguments); + } + + /// + /// Verify that CreateAlwaysApproveToolWithArgumentsResponse sets AlwaysApproveTool to false. + /// + [Fact] + public void CreateAlwaysApproveToolWithArgumentsResponse_AlwaysApproveTool_IsFalse() + { + // Arrange + var request = CreateRequest("MyTool"); + + // Act + var result = request.CreateAlwaysApproveToolWithArgumentsResponse(); + + // Assert + Assert.False(result.AlwaysApproveTool); + } + + /// + /// Verify that CreateAlwaysApproveToolWithArgumentsResponse creates an approved inner response. + /// + [Fact] + public void CreateAlwaysApproveToolWithArgumentsResponse_InnerResponse_IsApproved() + { + // Arrange + var request = CreateRequest("MyTool"); + + // Act + var result = request.CreateAlwaysApproveToolWithArgumentsResponse(); + + // Assert + Assert.True(result.InnerResponse.Approved); + } + + /// + /// Verify that CreateAlwaysApproveToolWithArgumentsResponse forwards the reason. + /// + [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); + } + + /// + /// Verify that CreateAlwaysApproveToolWithArgumentsResponse throws on null request. + /// + [Fact] + public void CreateAlwaysApproveToolWithArgumentsResponse_NullRequest_Throws() + { + // Act & Assert + Assert.Throws("request", + () => ((ToolApprovalRequestContent)null!).CreateAlwaysApproveToolWithArgumentsResponse()); + } + + #endregion + + #region AlwaysApproveToolApprovalResponseContent Properties + + /// + /// Verify that the content is an AIContent subclass. + /// + [Fact] + public void Content_IsAIContentSubclass() + { + // Arrange + var request = CreateRequest("MyTool"); + + // Act + var result = request.CreateAlwaysApproveToolResponse(); + + // Assert + Assert.IsAssignableFrom(result); + } + + /// + /// Verify that InnerResponse preserves tool call arguments. + /// + [Fact] + public void InnerResponse_PreservesArguments() + { + // Arrange + var args = new Dictionary { ["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(result.InnerResponse.ToolCall); + Assert.Equal(2, functionCall.Arguments!.Count); + Assert.Equal("test.txt", functionCall.Arguments["path"]); + } + + /// + /// Verify that both factory methods produce distinct instances from the same request. + /// + [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 +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/ToolApprovalAgentBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/ToolApprovalAgentBuilderExtensionsTests.cs new file mode 100644 index 0000000000..9e43d2c0dc --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/ToolApprovalAgentBuilderExtensionsTests.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Unit tests for the class. +/// +public class ToolApprovalAgentBuilderExtensionsTests +{ + /// + /// Verify that UseToolApproval throws ArgumentNullException when builder is null. + /// + [Fact] + public void UseToolApproval_WithNullBuilder_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws("builder", () => ((AIAgentBuilder)null!).UseToolApproval()); + } + + /// + /// Verify that UseToolApproval returns a ToolApprovalAgent. + /// + [Fact] + public void UseToolApproval_WithValidBuilder_ReturnsToolApprovalAgent() + { + // Arrange + var mockAgent = new Mock(); + var builder = new AIAgentBuilder(mockAgent.Object); + + // Act + var result = builder.UseToolApproval().Build(); + + // Assert + Assert.IsType(result); + } + + /// + /// Verify that UseToolApproval returns the same builder instance for chaining. + /// + [Fact] + public void UseToolApproval_ReturnsBuilderForChaining() + { + // Arrange + var mockAgent = new Mock(); + var builder = new AIAgentBuilder(mockAgent.Object); + + // Act + var result = builder.UseToolApproval(); + + // Assert + Assert.Same(builder, result); + } + + /// + /// Verify that UseToolApproval with custom JsonSerializerOptions works correctly. + /// + [Fact] + public void UseToolApproval_WithCustomJsonSerializerOptions_ReturnsToolApprovalAgent() + { + // Arrange + var mockAgent = new Mock(); + var builder = new AIAgentBuilder(mockAgent.Object); + var options = new JsonSerializerOptions(); + + // Act + var result = builder.UseToolApproval(jsonSerializerOptions: 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 new file mode 100644 index 0000000000..41c7c473aa --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/ToolApprovalAgentTests.cs @@ -0,0 +1,1538 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; +using Moq.Protected; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Unit tests for the class. +/// +public class ToolApprovalAgentTests +{ + #region Constructor + + /// + /// Verify that constructor throws ArgumentNullException when innerAgent is null. + /// + [Fact] + public void Constructor_NullInnerAgent_ThrowsAsync() + { + // Act & Assert + Assert.Throws("innerAgent", () => new ToolApprovalAgent(null!)); + } + + /// + /// Verify that constructor creates a valid instance. + /// + [Fact] + public void Constructor_ValidInnerAgent_CreatesInstanceAsync() + { + // Arrange + var innerAgent = new Mock().Object; + + // Act + var agent = new ToolApprovalAgent(innerAgent); + + // Assert + Assert.NotNull(agent); + } + + /// + /// Verify that constructor accepts custom JsonSerializerOptions. + /// + [Fact] + public void Constructor_CustomJsonSerializerOptions_CreatesInstanceAsync() + { + // Arrange + var innerAgent = new Mock().Object; + var options = new JsonSerializerOptions(); + + // Act + var agent = new ToolApprovalAgent(innerAgent, options); + + // Assert + Assert.NotNull(agent); + } + + #endregion + + #region RunAsync - Passthrough + + /// + /// Verify that when there are no approval requests, response passes through unchanged. + /// + [Fact] + public async Task RunAsync_NoApprovalRequests_PassesThroughAsync() + { + // Arrange + var expectedResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Hello")]); + var innerAgent = CreateMockAgent(expectedResponse); + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Act + var response = await agent.RunAsync( + [new ChatMessage(ChatRole.User, "Hi")], + new ChatClientAgentSession()); + + // Assert + Assert.Equal("Hello", response.Text); + } + + /// + /// Verify that approval requests with no matching rules are surfaced to the caller. + /// + [Fact] + public async Task RunAsync_ApprovalRequestNoRule_SurfacesToCallerAsync() + { + // Arrange + var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool")); + var responseMessage = new ChatMessage(ChatRole.Assistant, [approvalRequest]); + var expectedResponse = new AgentResponse([responseMessage]); + var innerAgent = CreateMockAgent(expectedResponse); + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Act + var response = await agent.RunAsync( + [new ChatMessage(ChatRole.User, "Hi")], + new ChatClientAgentSession()); + + // Assert + var requests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); + Assert.Single(requests); + Assert.Equal("MyTool", ((FunctionCallContent)requests[0].ToolCall).Name); + } + + #endregion + + #region RunAsync - Deferred Auto-Approve + + /// + /// Verify that when a tool-level rule exists, matching approval requests are + /// auto-approved immediately and the inner agent is re-called in the same run. + /// + [Fact] + public async Task RunAsync_ToolLevelRule_DeferredAutoApproveAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool")); + + // Call 1: establish rule + inner agent returns approval request → auto-approved → re-call inner agent + var approvalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalRequest])]); + 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 ? approvalResponse : finalResponse); + + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Call 1: send always-approve → establishes rule, inner returns TARc → auto-approved → re-calls inner + var alwaysApproveResponse = approvalRequest.CreateAlwaysApproveToolResponse("User said always"); + var response1 = await agent.RunAsync( + [new ChatMessage(ChatRole.User, [alwaysApproveResponse])], + session); + + // Assert — inner agent was called twice within the same RunAsync call + Assert.Equal(2, callCount); + Assert.Equal("Done", response1.Text); + + // Response should NOT surface the auto-approved approval request to caller + var surfacedRequests = response1.Messages.SelectMany(m => m.Contents).OfType().ToList(); + Assert.Empty(surfacedRequests); + + // Verify that the re-call received the injected auto-approval response + Assert.NotNull(secondCallMessages); + var injectedApprovals = secondCallMessages!.SelectMany(m => m.Contents).OfType().ToList(); + Assert.Single(injectedApprovals); + Assert.True(injectedApprovals[0].Approved); + } + + /// + /// Verify that a tool+arguments rule stores pending auto-approvals for matching calls. + /// + [Fact] + public async Task RunAsync_ToolWithArgsRule_DeferredAutoApproveAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var args = new Dictionary { ["path"] = "test.txt" }; + var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ReadFile", args)); + var approvalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalRequest])]); + var finalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "File content")]); + + var callCount = 0; + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => + { + callCount++; + return callCount == 1 ? approvalResponse : finalResponse; + }); + + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Call 1: set up the rule + var alwaysApproveResponse = approvalRequest.CreateAlwaysApproveToolWithArgumentsResponse(); + await agent.RunAsync( + [new ChatMessage(ChatRole.User, [alwaysApproveResponse])], + session); + + // Call 2: pending auto-approval injected + var response = await agent.RunAsync( + [new ChatMessage(ChatRole.User, "Continue")], + session); + + // Assert + Assert.Equal("File content", response.Text); + } + + /// + /// Verify that a tool+arguments rule does NOT auto-approve when arguments differ. + /// + [Fact] + public async Task RunAsync_ToolWithArgsRule_DoesNotAutoApproveDifferentArgsAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + + // Set up rule with args { path: "test.txt" } + var ruleArgs = new Dictionary { ["path"] = "test.txt" }; + var ruleRequest = new ToolApprovalRequestContent("req0", new FunctionCallContent("call0", "ReadFile", ruleArgs)); + var alwaysApproveResponse = ruleRequest.CreateAlwaysApproveToolWithArgumentsResponse(); + + // Then the inner agent returns an approval for DIFFERENT args + var differentArgs = new Dictionary { ["path"] = "other.txt" }; + var newApprovalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ReadFile", differentArgs)); + var approvalResponseMsg = new AgentResponse([new ChatMessage(ChatRole.Assistant, [newApprovalRequest])]); + + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(approvalResponseMsg); + + var agent = new ToolApprovalAgent(innerAgent.Object); + var inputMessages = new List + { + new(ChatRole.User, [alwaysApproveResponse]), + }; + + // Act + var response = await agent.RunAsync(inputMessages, session); + + // Assert — the approval request should surface to the caller (not auto-approved) + var requests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); + Assert.Single(requests); + Assert.Equal("ReadFile", ((FunctionCallContent)requests[0].ToolCall).Name); + } + + #endregion + + #region Mixed Auto-Approve + + /// + /// Verify that when some approval requests match rules and others don't, + /// matching ones are stored as pending and non-matching are surfaced. + /// + [Fact] + public async Task RunAsync_MixedApprovalRequests_SurfacesNonMatchingStoreMatchingAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + + // Set up a rule for ToolA only + var ruleRequest = new ToolApprovalRequestContent("rule-req", new FunctionCallContent("rule-call", "ToolA")); + var alwaysApprove = ruleRequest.CreateAlwaysApproveToolResponse(); + + // Inner agent returns approval requests for both ToolA and ToolB + var approvalA = new ToolApprovalRequestContent("reqA", new FunctionCallContent("callA", "ToolA")); + var approvalB = new ToolApprovalRequestContent("reqB", new FunctionCallContent("callB", "ToolB")); + var mixedResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalA, approvalB])]); + + var innerAgent = CreateMockAgent(mixedResponse); + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Call 1: establish rule + get mixed approval response + var response = await agent.RunAsync( + [new ChatMessage(ChatRole.User, [alwaysApprove])], + session); + + // Assert — ToolB request surfaced to caller, ToolA auto-approved is removed from response + var surfacedRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); + Assert.Single(surfacedRequests); + Assert.Equal("ToolB", ((FunctionCallContent)surfacedRequests[0].ToolCall).Name); + + // Call 2: verify pending auto-approval for ToolA is injected + List? capturedMessages = null; + var finalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Final")]); + var callCount = 0; + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback, AgentSession?, AgentRunOptions?, CancellationToken>((msgs, _, _, _) => + { + callCount++; + capturedMessages = msgs.ToList(); + }) + .ReturnsAsync(finalResponse); + + // User manually approves ToolB and sends along + var toolBApproval = approvalB.CreateResponse(approved: true); + await agent.RunAsync( + [new ChatMessage(ChatRole.User, [toolBApproval])], + session); + + // Verify pending auto-approval for ToolA was injected + Assert.NotNull(capturedMessages); + var allApprovals = capturedMessages!.SelectMany(m => m.Contents).OfType().ToList(); + Assert.Equal(2, allApprovals.Count); // ToolA auto-approval + ToolB manual approval + } + + #endregion + + #region Content Ordering + + /// + /// Verify that content ordering is preserved when unwrapping AlwaysApproveToolApprovalResponseContent. + /// + [Fact] + public async Task RunAsync_UnwrapPreservesContentOrderAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool")); + var alwaysApprove = approvalRequest.CreateAlwaysApproveToolResponse(); + + // Message with mixed content: text before, always-approve in middle, text after + var textBefore = new TextContent("Before"); + var textAfter = new TextContent("After"); + var inputMessage = new ChatMessage(ChatRole.User, [textBefore, alwaysApprove, textAfter]); + + List? capturedMessages = null; + var finalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "OK")]); + + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback, AgentSession?, AgentRunOptions?, CancellationToken>((msgs, _, _, _) => + capturedMessages = msgs.ToList()) + .ReturnsAsync(finalResponse); + + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Act + await agent.RunAsync([inputMessage], session); + + // Assert — content order should be: TextContent("Before"), ToolApprovalResponseContent, TextContent("After") + Assert.NotNull(capturedMessages); + var contents = capturedMessages![0].Contents; + Assert.Equal(3, contents.Count); + Assert.IsType(contents[0]); + Assert.Equal("Before", ((TextContent)contents[0]).Text); + Assert.IsType(contents[1]); + Assert.IsType(contents[2]); + Assert.Equal("After", ((TextContent)contents[2]).Text); + } + + #endregion + + #region Unwrapping + + /// + /// Verify that AlwaysApproveToolApprovalResponseContent is unwrapped to the inner ToolApprovalResponseContent + /// before being forwarded to the inner agent. + /// + [Fact] + public async Task RunAsync_UnwrapsAlwaysApproveResponse_ForwardsInnerResponseAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool")); + var alwaysApprove = approvalRequest.CreateAlwaysApproveToolResponse(); + + List? capturedMessages = null; + var finalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "OK")]); + + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback, AgentSession?, AgentRunOptions?, CancellationToken>((msgs, _, _, _) => + capturedMessages = msgs.ToList()) + .ReturnsAsync(finalResponse); + + var agent = new ToolApprovalAgent(innerAgent.Object); + var inputMessages = new List + { + new(ChatRole.User, [alwaysApprove]), + }; + + // Act + await agent.RunAsync(inputMessages, session); + + // Assert — the forwarded message should contain ToolApprovalResponseContent, not AlwaysApproveToolApprovalResponseContent + Assert.NotNull(capturedMessages); + var contents = capturedMessages!.SelectMany(m => m.Contents).ToList(); + Assert.DoesNotContain(contents, c => c is AlwaysApproveToolApprovalResponseContent); + Assert.Contains(contents, c => c is ToolApprovalResponseContent); + } + + #endregion + + #region Rule Persistence + + /// + /// Verify that rules persist across multiple RunAsync calls on the same session, + /// and that auto-approved TARc are immediately handled via re-call within the same run. + /// + [Fact] + public async Task RunAsync_RulesPersistAcrossCallsAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool")); + var alwaysApprove = approvalRequest.CreateAlwaysApproveToolResponse(); + + // Call 1: no approval requests in response, just establish the rule + var firstResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "OK")]); + // Call 2a: return an approval request that should match stored rule → auto-approved → re-call + var secondApproval = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "MyTool")); + var secondResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [secondApproval])]); + // Call 2b: re-call after auto-approve, final response + var thirdResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done after auto-approve")]); + + var callCount = 0; + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => + { + callCount++; + return callCount switch + { + 1 => firstResponse, + 2 => secondResponse, + _ => thirdResponse, + }; + }); + + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Call 1: establish rule (no approval requests returned) + await agent.RunAsync( + [new ChatMessage(ChatRole.User, [alwaysApprove])], + session); + + // Call 2: inner agent returns TARc → matches rule → auto-approved → re-call → final response + var response2 = await agent.RunAsync( + [new ChatMessage(ChatRole.User, "Do something")], + session); + + // Assert — inner agent called 3 times total (1 for rule setup, 2 for auto-approve loop) + Assert.Equal("Done after auto-approve", response2.Text); + Assert.Equal(3, callCount); + } + + /// + /// Verify that collected approval responses are cleared after injection (during the loop re-call). + /// A subsequent RunAsync call should not inject them again. + /// + [Fact] + public async Task RunAsync_CollectedApprovalResponses_ClearedAfterInjectionAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool")); + var alwaysApprove = approvalRequest.CreateAlwaysApproveToolResponse(); + + // Call 1: establish rule + var firstResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "OK")]); + // Call 2a: return approval → auto-approved → loop + var approval = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "MyTool")); + var secondResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [approval])]); + // Call 2b: re-call after auto-approve (injected) + var thirdResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Injected")]); + // Call 3: no pending (already cleared by the loop) + var fourthResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Clean")]); + + var callCount = 0; + List? lastCallMessages = null; + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback, AgentSession?, AgentRunOptions?, CancellationToken>((msgs, _, _, _) => + { + callCount++; + lastCallMessages = msgs.ToList(); + }) + .ReturnsAsync(() => callCount switch + { + 1 => firstResponse, + 2 => secondResponse, + 3 => thirdResponse, + _ => fourthResponse, + }); + + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Call 1: establish rule (callCount → 1) + await agent.RunAsync([new ChatMessage(ChatRole.User, [alwaysApprove])], session); + + // Call 2: inner returns TARc → auto-approved → loop → callCount → 2, 3 + await agent.RunAsync([new ChatMessage(ChatRole.User, "Trigger approval")], session); + + // Call 3: no pending (cleared by the loop), callCount → 4 + var response3 = await agent.RunAsync([new ChatMessage(ChatRole.User, "No pending")], session); + + // Assert — last call should only have the user message, no injected approvals + Assert.NotNull(lastCallMessages); + var approvals = lastCallMessages!.SelectMany(m => m.Contents).OfType().ToList(); + Assert.Empty(approvals); + Assert.Equal("Clean", response3.Text); + } + + #endregion + + #region RunStreamingAsync + + /// + /// Verify that streaming passthrough works when there are no approval requests. + /// + [Fact] + public async Task RunStreamingAsync_NoApprovalRequests_PassesThroughAsync() + { + // Arrange + var updates = new[] { new AgentResponseUpdate(ChatRole.Assistant, "Hello") }; + var innerAgent = CreateMockStreamingAgent(updates); + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Act + var results = new List(); + await foreach (var update in agent.RunStreamingAsync( + [new ChatMessage(ChatRole.User, "Hi")], + new ChatClientAgentSession())) + { + results.Add(update); + } + + // Assert + Assert.Single(results); + Assert.Equal("Hello", results[0].Text); + } + + #endregion + + #region MatchesRule + + /// + /// Verify that a tool-level rule matches regardless of arguments. + /// + [Fact] + public void MatchesRule_ToolLevelRule_MatchesAnyArgs() + { + // Arrange + var rules = new List + { + new() { ToolName = "MyTool" }, + }; + var request = new ToolApprovalRequestContent("req1", + new FunctionCallContent("call1", "MyTool", new Dictionary { ["x"] = "1" })); + + // Act & Assert + Assert.True(ToolApprovalAgent.MatchesRule(request, rules, AgentJsonUtilities.DefaultOptions)); + } + + /// + /// Verify that a tool-level rule does not match a different tool name. + /// + [Fact] + public void MatchesRule_ToolLevelRule_DoesNotMatchDifferentTool() + { + // Arrange + var rules = new List + { + new() { ToolName = "ToolA" }, + }; + var request = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ToolB")); + + // Act & Assert + Assert.False(ToolApprovalAgent.MatchesRule(request, rules, AgentJsonUtilities.DefaultOptions)); + } + + /// + /// Verify that a tool+arguments rule matches with exact same arguments. + /// + [Fact] + public void MatchesRule_ToolWithArgsRule_MatchesExactArgs() + { + // Arrange — rule arguments must be in serialized form (JSON string representation) + var rules = new List + { + new() + { + ToolName = "ReadFile", + Arguments = new Dictionary { ["path"] = "\"test.txt\"" }, + }, + }; + var request = new ToolApprovalRequestContent("req1", + new FunctionCallContent("call1", "ReadFile", new Dictionary { ["path"] = "test.txt" })); + + // Act & Assert + Assert.True(ToolApprovalAgent.MatchesRule(request, rules, AgentJsonUtilities.DefaultOptions)); + } + + /// + /// Verify that a tool+arguments rule does not match when argument values differ. + /// + [Fact] + public void MatchesRule_ToolWithArgsRule_DoesNotMatchDifferentValues() + { + // Arrange + var rules = new List + { + new() + { + ToolName = "ReadFile", + Arguments = new Dictionary { ["path"] = "\"test.txt\"" }, + }, + }; + var request = new ToolApprovalRequestContent("req1", + new FunctionCallContent("call1", "ReadFile", new Dictionary { ["path"] = "other.txt" })); + + // Act & Assert + Assert.False(ToolApprovalAgent.MatchesRule(request, rules, AgentJsonUtilities.DefaultOptions)); + } + + /// + /// Verify that a tool+arguments rule does not match when argument count differs. + /// + [Fact] + public void MatchesRule_ToolWithArgsRule_DoesNotMatchDifferentArgCount() + { + // Arrange + var rules = new List + { + new() + { + ToolName = "ReadFile", + Arguments = new Dictionary { ["path"] = "\"test.txt\"" }, + }, + }; + var request = new ToolApprovalRequestContent("req1", + new FunctionCallContent("call1", "ReadFile", new Dictionary + { + ["path"] = "test.txt", + ["encoding"] = "utf-8", + })); + + // Act & Assert + Assert.False(ToolApprovalAgent.MatchesRule(request, rules, AgentJsonUtilities.DefaultOptions)); + } + + /// + /// Verify that a non-FunctionCallContent tool call does not match any rule. + /// + [Fact] + public void MatchesRule_NonFunctionCallContent_ReturnsFalse() + { + // Arrange + var rules = new List + { + new() { ToolName = "MyTool" }, + }; + var request = new ToolApprovalRequestContent("req1", new ToolCallContent("call1")); + + // Act & Assert + Assert.False(ToolApprovalAgent.MatchesRule(request, rules, AgentJsonUtilities.DefaultOptions)); + } + + /// + /// Verify that matching works with JsonElement argument values. + /// + [Fact] + public void MatchesRule_JsonElementArgs_MatchesCorrectly() + { + // Arrange + var rules = new List + { + new() + { + ToolName = "MyTool", + Arguments = new Dictionary { ["count"] = "42" }, + }, + }; + var jsonElement = JsonDocument.Parse("42").RootElement; + var request = new ToolApprovalRequestContent("req1", + new FunctionCallContent("call1", "MyTool", new Dictionary { ["count"] = jsonElement })); + + // Act & Assert + Assert.True(ToolApprovalAgent.MatchesRule(request, rules, AgentJsonUtilities.DefaultOptions)); + } + + #endregion + + #region Extension Methods + + /// + /// Verify that CreateAlwaysApproveToolResponse creates the correct wrapper. + /// + [Fact] + public void CreateAlwaysApproveToolResponse_SetsCorrectFlags() + { + // Arrange + var request = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool")); + + // Act + var result = request.CreateAlwaysApproveToolResponse("Test reason"); + + // Assert + Assert.True(result.AlwaysApproveTool); + Assert.False(result.AlwaysApproveToolWithArguments); + Assert.True(result.InnerResponse.Approved); + Assert.Equal("Test reason", result.InnerResponse.Reason); + Assert.Equal("req1", result.InnerResponse.RequestId); + } + + /// + /// Verify that CreateAlwaysApproveToolWithArgumentsResponse creates the correct wrapper. + /// + [Fact] + public void CreateAlwaysApproveToolWithArgumentsResponse_SetsCorrectFlags() + { + // Arrange + var request = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool")); + + // Act + var result = request.CreateAlwaysApproveToolWithArgumentsResponse(); + + // Assert + Assert.False(result.AlwaysApproveTool); + Assert.True(result.AlwaysApproveToolWithArguments); + Assert.True(result.InnerResponse.Approved); + } + + /// + /// Verify that extension methods throw on null request. + /// + [Fact] + public void CreateAlwaysApproveToolResponse_NullRequest_Throws() + { + // Act & Assert + Assert.Throws("request", + () => ((ToolApprovalRequestContent)null!).CreateAlwaysApproveToolResponse()); + } + + /// + /// Verify that extension methods throw on null request. + /// + [Fact] + public void CreateAlwaysApproveToolWithArgumentsResponse_NullRequest_Throws() + { + // Act & Assert + Assert.Throws("request", + () => ((ToolApprovalRequestContent)null!).CreateAlwaysApproveToolWithArgumentsResponse()); + } + + #endregion + + #region Duplicate Rule Prevention + + /// + /// Verify that sending the same always-approve response twice does not create duplicate rules. + /// + [Fact] + public async Task RunAsync_DuplicateAlwaysApprove_DoesNotDuplicateRuleAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var request1 = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool")); + var request2 = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "MyTool")); + + var finalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "OK")]); + var innerAgent = CreateMockAgent(finalResponse); + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Act — send two always-approve responses for the same tool + await agent.RunAsync( + [new ChatMessage(ChatRole.User, [request1.CreateAlwaysApproveToolResponse()])], + session); + await agent.RunAsync( + [new ChatMessage(ChatRole.User, [request2.CreateAlwaysApproveToolResponse()])], + session); + + // Assert — verify the state works correctly (rule still matches on subsequent call) + var thirdApproval = new ToolApprovalRequestContent("req3", new FunctionCallContent("call3", "MyTool")); + var approvalResponseMsg = new AgentResponse([new ChatMessage(ChatRole.Assistant, [thirdApproval])]); + var afterAutoResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Auto-approved")]); + + var callCount = 0; + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => + { + callCount++; + return callCount == 1 ? approvalResponseMsg : afterAutoResponse; + }); + + // Call 3: triggers approval → stored as pending + await agent.RunAsync([new ChatMessage(ChatRole.User, "test")], session); + + // Call 4: pending injected + var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "continue")], session); + Assert.Equal("Auto-approved", response.Text); + } + + #endregion + + #region Auto-Approved Removal + + /// + /// Verify that auto-approved requests are removed from the non-streaming response + /// and the inner agent is re-called, returning actual content. + /// + [Fact] + public async Task RunAsync_AutoApprovedRequest_RemovedFromResponseAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool")); + var alwaysApprove = approvalRequest.CreateAlwaysApproveToolResponse(); + + // Inner agent returns a TARc on first call, then real content on second + var approvalResponseContent = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "MyTool")); + var firstResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalResponseContent])]); + var secondResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Tool executed successfully")]); + + var innerAgent = new Mock(); + var callCount = 0; + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => ++callCount == 1 ? firstResponse : secondResponse); + + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Act: establish rule + inner returns matching approval request → auto-approved → re-call + var response = await agent.RunAsync( + [new ChatMessage(ChatRole.User, [alwaysApprove])], + session); + + // Assert — auto-approved request removed, inner agent called twice, final response has text + var allRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); + Assert.Empty(allRequests); + Assert.Equal("Tool executed successfully", response.Messages[0].Text); + Assert.Equal(2, callCount); + } + + /// + /// Verify that auto-approved requests are removed from streaming updates + /// and the inner agent is re-called, yielding actual content. + /// + [Fact] + public async Task RunStreamingAsync_AutoApprovedRequest_RemovedFromUpdatesAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool")); + + // First establish a rule via non-streaming + var alwaysApprove = approvalRequest.CreateAlwaysApproveToolResponse(); + var setupResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "OK")]); + + var innerAgent = new Mock(); + + // Setup non-streaming for rule establishment + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(setupResponse); + + // First streaming call: text + auto-approvable TARc. Second call: just text (tool executed). + var streamApproval = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "MyTool")); + var textUpdate = new AgentResponseUpdate(ChatRole.Assistant, "Hello"); + var approvalUpdate = new AgentResponseUpdate(ChatRole.Assistant, new List { streamApproval }); + var finalUpdate = new AgentResponseUpdate(ChatRole.Assistant, "Done"); + + var streamCallCount = 0; + innerAgent + .Protected() + .Setup>("RunCoreStreamingAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Returns(() => ++streamCallCount == 1 + ? ToAsyncEnumerableAsync(new[] { textUpdate, approvalUpdate }) + : ToAsyncEnumerableAsync(new[] { finalUpdate })); + + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Establish the rule + await agent.RunAsync( + [new ChatMessage(ChatRole.User, [alwaysApprove])], + session); + + // Act — stream with auto-approvable request + var results = new List(); + await foreach (var update in agent.RunStreamingAsync( + [new ChatMessage(ChatRole.User, "Do something")], + session)) + { + results.Add(update); + } + + // Assert — text from first call + final text from re-call; no TARc surfaced + Assert.Equal(2, results.Count); + Assert.Equal("Hello", results[0].Text); + Assert.Equal("Done", results[1].Text); + Assert.Equal(2, streamCallCount); + } + + /// + /// Verify that non-matching approval requests remain in streaming updates. + /// + [Fact] + public async Task RunStreamingAsync_NonMatchingRequest_SurfacedToCallerAsync() + { + // Arrange + var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "UnknownTool")); + var approvalUpdate = new AgentResponseUpdate(ChatRole.Assistant, new List { approvalRequest }); + + var innerAgent = CreateMockStreamingAgent([approvalUpdate]); + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Act + var results = new List(); + await foreach (var update in agent.RunStreamingAsync( + [new ChatMessage(ChatRole.User, "Hi")], + new ChatClientAgentSession())) + { + results.Add(update); + } + + // Assert — non-matching request should be surfaced + Assert.Single(results); + var requests = results[0].Contents.OfType().ToList(); + Assert.Single(requests); + Assert.Equal("UnknownTool", ((FunctionCallContent)requests[0].ToolCall).Name); + } + + /// + /// Verify that a message with only auto-approved content is removed from the response + /// when the inner agent is re-called after auto-approving. + /// + [Fact] + public async Task RunAsync_MessageWithOnlyAutoApprovedContent_RemovedEntirelyAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var ruleRequest = new ToolApprovalRequestContent("rule-req", new FunctionCallContent("rule-call", "MyTool")); + var alwaysApprove = ruleRequest.CreateAlwaysApproveToolResponse(); + + // First call: returns TARc-only message + text message. Second call: returns just text (tools executed). + var approvalContent = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool")); + var approvalMessage = new ChatMessage(ChatRole.Assistant, [approvalContent]); + var textMessage = new ChatMessage(ChatRole.Assistant, "Final text"); + var firstResponse = new AgentResponse([approvalMessage, textMessage]); + var secondResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Tools executed")]); + + var callCount = 0; + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => ++callCount == 1 ? firstResponse : secondResponse); + + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Act + var response = await agent.RunAsync( + [new ChatMessage(ChatRole.User, [alwaysApprove])], + session); + + // Assert — inner agent re-called after auto-approve, final response from second call + Assert.Equal(2, callCount); + Assert.Single(response.Messages); + Assert.Equal("Tools executed", response.Messages[0].Text); + } + + /// + /// Verify that a streaming update with mixed content (auto-approved + non-auto-approved) only removes the auto-approved part. + /// + [Fact] + public async Task RunStreamingAsync_MixedUpdate_OnlyRemovesAutoApprovedAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var ruleRequest = new ToolApprovalRequestContent("rule-req", new FunctionCallContent("rule-call", "ToolA")); + var alwaysApprove = ruleRequest.CreateAlwaysApproveToolResponse(); + + // Setup non-streaming for rule establishment + var setupResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "OK")]); + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(setupResponse); + + // Streaming update with both auto-approvable (ToolA) and non-auto-approvable (ToolB) requests + var autoApprovable = new ToolApprovalRequestContent("reqA", new FunctionCallContent("callA", "ToolA")); + var notAutoApprovable = new ToolApprovalRequestContent("reqB", new FunctionCallContent("callB", "ToolB")); + var mixedUpdate = new AgentResponseUpdate(ChatRole.Assistant, new List { autoApprovable, notAutoApprovable }); + + innerAgent + .Protected() + .Setup>("RunCoreStreamingAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Returns(ToAsyncEnumerableAsync(new[] { mixedUpdate })); + + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Establish rule + await agent.RunAsync( + [new ChatMessage(ChatRole.User, [alwaysApprove])], + session); + + // Act + var results = new List(); + await foreach (var update in agent.RunStreamingAsync( + [new ChatMessage(ChatRole.User, "Do something")], + session)) + { + results.Add(update); + } + + // Assert — only ToolB request should remain + Assert.Single(results); + var requests = results[0].Contents.OfType().ToList(); + Assert.Single(requests); + Assert.Equal("ToolB", ((FunctionCallContent)requests[0].ToolCall).Name); + } + + #endregion + + #region Queue Behavior - Non-Streaming + + /// + /// Verify that when the inner agent returns multiple unapproved TARc, only the first is returned + /// and the rest are queued. + /// + [Fact] + public async Task RunAsync_MultipleTARc_ReturnsOnlyFirstAsync() + { + // Arrange + var tarc1 = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ToolA")); + var tarc2 = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "ToolB")); + var tarc3 = new ToolApprovalRequestContent("req3", new FunctionCallContent("call3", "ToolC")); + var innerResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [tarc1, tarc2, tarc3])]); + var innerAgent = CreateMockAgent(innerResponse); + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Act + var response = await agent.RunAsync( + [new ChatMessage(ChatRole.User, "Do something")], + new ChatClientAgentSession()); + + // Assert — only the first TARc should be returned + var requests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); + Assert.Single(requests); + Assert.Equal("ToolA", ((FunctionCallContent)requests[0].ToolCall).Name); + } + + /// + /// Verify that after approving the first queued item, the second is returned on the next call + /// without calling the inner agent. + /// + [Fact] + public async Task RunAsync_ApproveFirst_ReturnsSecondFromQueueAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var tarc1 = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ToolA")); + var tarc2 = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "ToolB")); + var tarc3 = new ToolApprovalRequestContent("req3", new FunctionCallContent("call3", "ToolC")); + var innerResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [tarc1, tarc2, tarc3])]); + + var callCount = 0; + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback, AgentSession?, AgentRunOptions?, CancellationToken>((_, _, _, _) => callCount++) + .ReturnsAsync(innerResponse); + + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Call 1: get first TARc + var response1 = await agent.RunAsync( + [new ChatMessage(ChatRole.User, "Do something")], + session); + + // Call 2: approve first, get second from queue + var approval1 = tarc1.CreateResponse(approved: true, reason: "OK"); + var response2 = await agent.RunAsync( + [new ChatMessage(ChatRole.User, [approval1])], + session); + + // Assert — second TARc returned, inner agent called only once + Assert.Equal(1, callCount); + var requests = response2.Messages.SelectMany(m => m.Contents).OfType().ToList(); + Assert.Single(requests); + Assert.Equal("ToolB", ((FunctionCallContent)requests[0].ToolCall).Name); + } + + /// + /// Verify that "always approve" on the first queued item auto-approves matching items in the queue. + /// + [Fact] + public async Task RunAsync_AlwaysApproveFirst_AutoApprovesMatchingInQueueAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + + // Three TARc: two for ToolA (different args), one for ToolB + var tarc1 = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ToolA", arguments: new Dictionary { ["q"] = "query1" })); + var tarc2 = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "ToolA", arguments: new Dictionary { ["q"] = "query2" })); + var tarc3 = new ToolApprovalRequestContent("req3", new FunctionCallContent("call3", "ToolB")); + + var innerResponse1 = new AgentResponse([new ChatMessage(ChatRole.Assistant, [tarc1, tarc2, tarc3])]); + var finalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done")]); + + var callCount = 0; + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback, AgentSession?, AgentRunOptions?, CancellationToken>((_, _, _, _) => callCount++) + .ReturnsAsync(() => callCount == 1 ? innerResponse1 : finalResponse); + + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Call 1: get first TARc (ToolA query1) + var response1 = await agent.RunAsync( + [new ChatMessage(ChatRole.User, "Do something")], + session); + + Assert.Single(response1.Messages.SelectMany(m => m.Contents).OfType()); + + // Call 2: always approve ToolA → should auto-approve ToolA query2 in queue, return ToolB + var alwaysApprove = tarc1.CreateAlwaysApproveToolResponse("Always approve ToolA"); + var response2 = await agent.RunAsync( + [new ChatMessage(ChatRole.User, [alwaysApprove])], + session); + + var requests2 = response2.Messages.SelectMany(m => m.Contents).OfType().ToList(); + Assert.Single(requests2); + Assert.Equal("ToolB", ((FunctionCallContent)requests2[0].ToolCall).Name); + } + + /// + /// Verify that once all queued items are resolved, the inner agent is called with all collected responses. + /// + [Fact] + public async Task RunAsync_AllQueuedResolved_CallsInnerAgentWithCollectedResponsesAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var tarc1 = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ToolA")); + var tarc2 = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "ToolB")); + + var innerResponse1 = new AgentResponse([new ChatMessage(ChatRole.Assistant, [tarc1, tarc2])]); + var finalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "All tools executed")]); + + var callCount = 0; + List? thirdCallMessages = 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) + { + thirdCallMessages = msgs.ToList(); + } + }) + .ReturnsAsync(() => callCount == 1 ? innerResponse1 : finalResponse); + + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Call 1: get first TARc + await agent.RunAsync( + [new ChatMessage(ChatRole.User, "Do something")], + session); + + // Call 2: approve first, get second from queue + var approval1 = tarc1.CreateResponse(approved: true, reason: "OK"); + await agent.RunAsync( + [new ChatMessage(ChatRole.User, [approval1])], + session); + + // Call 3: approve second → queue empty → inner agent called + var approval2 = tarc2.CreateResponse(approved: true, reason: "OK"); + var response3 = await agent.RunAsync( + [new ChatMessage(ChatRole.User, [approval2])], + session); + + // Assert — inner agent called twice (initial + after queue resolved) + Assert.Equal(2, callCount); + Assert.Equal("All tools executed", response3.Text); + + // Verify collected responses were injected + Assert.NotNull(thirdCallMessages); + var injectedResponses = thirdCallMessages!.SelectMany(m => m.Contents).OfType().ToList(); + Assert.Equal(2, injectedResponses.Count); + } + + /// + /// Verify that a single TARc (no excess) is returned without queueing. + /// + [Fact] + public async Task RunAsync_SingleTARc_NoQueueingAsync() + { + // Arrange + var tarc = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool")); + var innerResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [tarc])]); + var innerAgent = CreateMockAgent(innerResponse); + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Act + var response = await agent.RunAsync( + [new ChatMessage(ChatRole.User, "Hi")], + new ChatClientAgentSession()); + + // Assert — single TARc returned directly + var requests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); + Assert.Single(requests); + Assert.Equal("MyTool", ((FunctionCallContent)requests[0].ToolCall).Name); + } + + /// + /// Verify that denying a queued item collects the denial and proceeds to the next. + /// + [Fact] + public async Task RunAsync_DenyFirst_ReturnsSecondFromQueueAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var tarc1 = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ToolA")); + var tarc2 = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "ToolB")); + var innerResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [tarc1, tarc2])]); + + var callCount = 0; + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback, AgentSession?, AgentRunOptions?, CancellationToken>((_, _, _, _) => callCount++) + .ReturnsAsync(innerResponse); + + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Call 1: get first TARc + await agent.RunAsync( + [new ChatMessage(ChatRole.User, "Do something")], + session); + + // Call 2: deny first, get second from queue + var denial = tarc1.CreateResponse(approved: false, reason: "No"); + var response2 = await agent.RunAsync( + [new ChatMessage(ChatRole.User, [denial])], + session); + + // Assert — second TARc returned, inner agent called only once + Assert.Equal(1, callCount); + var requests = response2.Messages.SelectMany(m => m.Contents).OfType().ToList(); + Assert.Single(requests); + Assert.Equal("ToolB", ((FunctionCallContent)requests[0].ToolCall).Name); + } + + #endregion + + #region Queue Behavior - Streaming + + /// + /// Verify that when streaming yields multiple unapproved TARc, only the first is yielded. + /// + [Fact] + public async Task RunStreamingAsync_MultipleTARc_YieldsOnlyFirstAsync() + { + // Arrange + var tarc1 = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ToolA")); + var tarc2 = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "ToolB")); + var approvalUpdate = new AgentResponseUpdate(ChatRole.Assistant, new List { tarc1, tarc2 }); + var textUpdate = new AgentResponseUpdate(ChatRole.Assistant, "Searching..."); + + var innerAgent = CreateMockStreamingAgent([textUpdate, approvalUpdate]); + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Act + var results = new List(); + await foreach (var update in agent.RunStreamingAsync( + [new ChatMessage(ChatRole.User, "Search")], + new ChatClientAgentSession())) + { + results.Add(update); + } + + // Assert — text update + one TARc + Assert.Equal(2, results.Count); + Assert.Equal("Searching...", results[0].Text); + var requests = results[1].Contents.OfType().ToList(); + Assert.Single(requests); + Assert.Equal("ToolA", ((FunctionCallContent)requests[0].ToolCall).Name); + } + + /// + /// Verify that streaming returns queued items one at a time on subsequent calls. + /// + [Fact] + public async Task RunStreamingAsync_ApproveFirst_ReturnsSecondFromQueueAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var tarc1 = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ToolA")); + var tarc2 = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "ToolB")); + var approvalUpdate = new AgentResponseUpdate(ChatRole.Assistant, new List { tarc1, tarc2 }); + + var callCount = 0; + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreStreamingAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback, AgentSession?, AgentRunOptions?, CancellationToken>((_, _, _, _) => callCount++) + .Returns(ToAsyncEnumerableAsync(new[] { approvalUpdate })); + + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Call 1: get first TARc + var results1 = new List(); + await foreach (var update in agent.RunStreamingAsync( + [new ChatMessage(ChatRole.User, "Search")], + session)) + { + results1.Add(update); + } + + Assert.Single(results1); + Assert.Equal("ToolA", ((FunctionCallContent)results1[0].Contents.OfType().Single().ToolCall).Name); + + // Call 2: approve first, get second from queue + var approval = tarc1.CreateResponse(approved: true, reason: "OK"); + var results2 = new List(); + await foreach (var update in agent.RunStreamingAsync( + [new ChatMessage(ChatRole.User, [approval])], + session)) + { + results2.Add(update); + } + + // Assert — second TARc returned from queue, inner agent called only once + Assert.Equal(1, callCount); + Assert.Single(results2); + Assert.Equal("ToolB", ((FunctionCallContent)results2[0].Contents.OfType().Single().ToolCall).Name); + } + + /// + /// Verify that "always approve" during streaming queue auto-approves matching items. + /// + [Fact] + public async Task RunStreamingAsync_AlwaysApproveFirst_AutoApprovesMatchingInQueueAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + + // Two ToolA calls + one ToolB call + var tarc1 = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ToolA")); + var tarc2 = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "ToolA")); + var tarc3 = new ToolApprovalRequestContent("req3", new FunctionCallContent("call3", "ToolB")); + var approvalUpdate = new AgentResponseUpdate(ChatRole.Assistant, new List { tarc1, tarc2, tarc3 }); + + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreStreamingAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Returns(ToAsyncEnumerableAsync(new[] { approvalUpdate })); + + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Call 1: get first TARc (ToolA) + var results1 = new List(); + await foreach (var update in agent.RunStreamingAsync( + [new ChatMessage(ChatRole.User, "Search")], + session)) + { + results1.Add(update); + } + + // Call 2: always approve ToolA → second ToolA auto-approved, returns ToolB + var alwaysApprove = tarc1.CreateAlwaysApproveToolResponse(); + var results2 = new List(); + await foreach (var update in agent.RunStreamingAsync( + [new ChatMessage(ChatRole.User, [alwaysApprove])], + session)) + { + results2.Add(update); + } + + // Assert — ToolB returned (ToolA[2] was auto-approved) + Assert.Single(results2); + var requests = results2[0].Contents.OfType().ToList(); + Assert.Single(requests); + Assert.Equal("ToolB", ((FunctionCallContent)requests[0].ToolCall).Name); + } + + #endregion + + #region Helpers + + private static Mock CreateMockAgent(AgentResponse response) + { + var mock = new Mock(); + mock + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(response); + return mock; + } + + private static Mock CreateMockStreamingAgent(AgentResponseUpdate[] updates) + { + var mock = new Mock(); + mock + .Protected() + .Setup>("RunCoreStreamingAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Returns(ToAsyncEnumerableAsync(updates)); + return mock; + } + + private static async IAsyncEnumerable ToAsyncEnumerableAsync( + IEnumerable items, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + foreach (var item in items) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return item; + await Task.Yield(); + } + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/ToolApprovalRuleTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/ToolApprovalRuleTests.cs new file mode 100644 index 0000000000..9cb9e617fe --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/ToolApprovalRuleTests.cs @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Unit tests for the class. +/// +public class ToolApprovalRuleTests +{ + #region Construction and Defaults + + /// + /// Verify that a new rule has the expected default values. + /// + [Fact] + public void NewRule_HasDefaultValues() + { + // Act + var rule = new ToolApprovalRule(); + + // Assert + Assert.Equal(string.Empty, rule.ToolName); + Assert.Null(rule.Arguments); + } + + /// + /// Verify that ToolName can be set. + /// + [Fact] + public void ToolName_CanBeSet() + { + // Arrange & Act + var rule = new ToolApprovalRule { ToolName = "ReadFile" }; + + // Assert + Assert.Equal("ReadFile", rule.ToolName); + } + + /// + /// Verify that Arguments can be set. + /// + [Fact] + public void Arguments_CanBeSet() + { + // Arrange & Act + var args = new Dictionary { ["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 + + /// + /// Verify that a tool-level rule round-trips through JSON serialization. + /// + [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(json, AgentJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal("MyTool", deserialized!.ToolName); + Assert.Null(deserialized.Arguments); + } + + /// + /// Verify that a tool+arguments rule round-trips through JSON serialization. + /// + [Fact] + public void Serialize_ToolWithArgsRule_RoundTrips() + { + // Arrange + var rule = new ToolApprovalRule + { + ToolName = "ReadFile", + Arguments = new Dictionary { ["path"] = "test.txt", ["encoding"] = "utf-8" }, + }; + + // Act + var json = JsonSerializer.Serialize(rule, AgentJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(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"]); + } + + /// + /// Verify that JSON property names are correctly applied. + /// + [Fact] + public void Serialize_UsesJsonPropertyNames() + { + // Arrange + var rule = new ToolApprovalRule + { + ToolName = "MyTool", + Arguments = new Dictionary { ["key"] = "value" }, + }; + + // Act + var json = JsonSerializer.Serialize(rule, AgentJsonUtilities.DefaultOptions); + + // Assert + Assert.Contains("\"toolName\"", json); + Assert.Contains("\"arguments\"", json); + } + + /// + /// Verify that a list of rules round-trips through JSON serialization. + /// + [Fact] + public void Serialize_RuleList_RoundTrips() + { + // Arrange + var rules = new List + { + new() { ToolName = "ToolA" }, + new() { ToolName = "ToolB", Arguments = new Dictionary { ["x"] = "1" } }, + }; + + // Act + var json = JsonSerializer.Serialize(rules, AgentJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize>(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 +}