// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI; /// /// A delegating chat client that automatically removes for tools /// that do not actually require approval, storing auto-approved results in the session for transparent /// re-injection on the next request. /// /// /// /// has an all-or-nothing behavior for approvals: when any tool /// in a response is an , it converts all /// items to — even for tools that do not require approval. This /// decorator sits above in the pipeline and transparently handles /// the non-approval-required items so callers only see approval requests for tools that truly need them. /// /// /// On outbound responses, the decorator identifies items for tools /// that are not wrapped in , removes them from the response, and /// stores them in the session's . On the next inbound request, the stored /// items are re-injected as pre-approved so that /// can process them alongside the caller's human-approved responses. /// /// /// This decorator requires an active with a non-null /// . An is thrown if no /// run context or session is available. /// /// internal sealed class NonApprovalRequiredFunctionBypassingChatClient : DelegatingChatClient { /// /// The key used in to store pending auto-approved function calls /// between agent runs. /// internal const string StateBagKey = "_autoApprovedFunctionCalls"; /// /// Initializes a new instance of the class. /// /// The underlying chat client (typically a ). public NonApprovalRequiredFunctionBypassingChatClient(IChatClient innerClient) : base(innerClient) { } /// public override async Task GetResponseAsync( IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) { var session = GetRequiredSession(); var autoApprovableNames = this.GetAutoApprovableToolNames(options); messages = InjectPendingAutoApprovals(messages, session); var response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); RemoveAutoApprovedFromMessages(response.Messages, autoApprovableNames, session); return response; } /// public override async IAsyncEnumerable GetStreamingResponseAsync( IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { var session = GetRequiredSession(); var autoApprovableNames = this.GetAutoApprovableToolNames(options); messages = InjectPendingAutoApprovals(messages, session); List? autoApproved = null; try { await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) { if (FilterUpdateContents(update, autoApprovableNames, ref autoApproved)) { yield return update; } } } finally { if (autoApproved is { Count: > 0 }) { session.StateBag.SetValue(StateBagKey, autoApproved, AgentJsonUtilities.DefaultOptions); } } } /// /// Gets the current from the ambient run context. /// /// No run context or session is available. private static AgentSession GetRequiredSession() { var runContext = AIAgent.CurrentRunContext ?? throw new InvalidOperationException( $"{nameof(NonApprovalRequiredFunctionBypassingChatClient)} can only be used within the context of a running AIAgent. " + "Ensure that the chat client is being invoked as part of an AIAgent.RunAsync or AIAgent.RunStreamingAsync call."); return runContext.Session ?? throw new InvalidOperationException( $"{nameof(NonApprovalRequiredFunctionBypassingChatClient)} requires a session. " + "Ensure the agent has a resolved session before invoking the chat client."); } /// /// Checks the session for stored auto-approvals from a previous turn and injects them as /// a user message containing items appended to the input messages. /// /// /// All stored requests are unconditionally injected as approved responses regardless of whether the /// tool set has changed, because the LLM requires a complete set of tool call responses for a prior turn. /// private static IEnumerable InjectPendingAutoApprovals( IEnumerable messages, AgentSession session) { if (!session.StateBag.TryGetValue>( StateBagKey, out var pendingRequests, AgentJsonUtilities.DefaultOptions) || pendingRequests is not { Count: > 0 }) { return messages; } session.StateBag.TryRemoveValue(StateBagKey); List approvalResponses = []; foreach (var request in pendingRequests) { approvalResponses.Add(request.CreateResponse(approved: true)); } var userMessage = new ChatMessage(ChatRole.User, approvalResponses); return messages.Concat([userMessage]); } /// /// Builds a set of tool names that do not require approval and can be auto-approved, /// by checking all available tools from and /// . /// private HashSet GetAutoApprovableToolNames(ChatOptions? options) { var ficc = this.GetService(); var allTools = (options?.Tools ?? Enumerable.Empty()) .Concat(ficc?.AdditionalTools ?? Enumerable.Empty()); return new HashSet( allTools .OfType() .Where(static f => f.GetService() is null) .Select(static f => f.Name), StringComparer.Ordinal); } /// /// Determines whether a can be auto-approved because /// the underlying tool is not an . /// /// /// if the approval request is for a known tool that does not require approval /// and can be auto-approved; otherwise. /// private static bool IsAutoApprovable(ToolApprovalRequestContent approval, HashSet autoApprovableNames) { if (approval.ToolCall is not FunctionCallContent fcc) { // Non-function tool calls cannot be auto-approved. return false; } // Auto-approve only if the tool is known and explicitly does NOT require approval. // Unknown tools are not in the set and are treated as approval-required (safe default). return autoApprovableNames.Contains(fcc.Name); } /// /// Scans response messages for auto-approvable items, /// removes them from the messages, and stores them in the session for the next request. /// private static void RemoveAutoApprovedFromMessages( IList messages, HashSet autoApprovableNames, AgentSession session) { List? autoApproved = null; foreach (var message in messages) { for (int i = message.Contents.Count - 1; i >= 0; i--) { if (message.Contents[i] is ToolApprovalRequestContent approval && IsAutoApprovable(approval, autoApprovableNames)) { (autoApproved ??= []).Add(approval); message.Contents.RemoveAt(i); } } } // Remove messages that are now empty after filtering. for (int i = messages.Count - 1; i >= 0; i--) { if (messages[i].Contents.Count == 0) { messages.RemoveAt(i); } } if (autoApproved is { Count: > 0 }) { session.StateBag.SetValue(StateBagKey, autoApproved, AgentJsonUtilities.DefaultOptions); } } /// /// Filters auto-approvable items from a streaming update's /// contents, collecting them for later storage. /// /// /// if the update should be yielded (has remaining content or had no /// approval content to begin with); if the update is now empty and /// should be skipped. /// private static bool FilterUpdateContents( ChatResponseUpdate update, HashSet autoApprovableNames, ref List? autoApproved) { bool hasApprovalContent = false; List filteredContents = []; bool removedAny = false; for (int i = 0; i < update.Contents.Count; i++) { var content = update.Contents[i]; if (content is ToolApprovalRequestContent approval) { hasApprovalContent = true; if (IsAutoApprovable(approval, autoApprovableNames)) { (autoApproved ??= []).Add(approval); removedAny = true; } else { filteredContents.Add(content); } } else { filteredContents.Add(content); } } if (removedAny) { update.Contents = filteredContents; } // Yield the update unless it was purely auto-approvable approval content (now empty). return update.Contents.Count > 0 || !hasApprovalContent; } }