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