.NET: Allow storage of auto-approved functions (#4950)

* Allow storage of auto-approved functions

* Address PR comments
This commit is contained in:
westey
2026-06-05 18:42:21 +01:00
committed by GitHub
Unverified
parent 9cafd7e58b
commit ab8ba8fc61
6 changed files with 934 additions and 0 deletions
@@ -181,6 +181,36 @@ public sealed class ChatClientAgentOptions
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public bool EnableMessageInjection { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to store automatically approved function calls in the session state
/// for tools that do not require approval when they are returned alongside tools that do.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="FunctionInvokingChatClient"/> has an all-or-nothing behavior for approvals: when any tool
/// in a response is an <see cref="ApprovalRequiredAIFunction"/>, it converts all <see cref="FunctionCallContent"/>
/// items to <see cref="ToolApprovalRequestContent"/>, even for tools that do not require approval.
/// </para>
/// <para>
/// Setting this property to <see langword="true"/> injects an <see cref="NonApprovalRequiredFunctionBypassingChatClient"/>
/// decorator above <see cref="FunctionInvokingChatClient"/> in the pipeline. This decorator identifies approval
/// requests for non-approval-required tools, removes them from the response, and stores them in the session.
/// On the next request, the stored items are automatically re-injected as approved, so the caller only needs
/// to handle approval requests for tools that truly require human approval.
/// </para>
/// <para>
/// This option has no effect when <see cref="UseProvidedChatClientAsIs"/> is <see langword="true"/>.
/// When using a custom chat client stack, you can add an <see cref="NonApprovalRequiredFunctionBypassingChatClient"/>
/// manually via the <see cref="ChatClientBuilderExtensions.UseNonApprovalRequiredFunctionBypassing"/>
/// extension method.
/// </para>
/// </remarks>
/// <value>
/// Default is <see langword="false"/>.
/// </value>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public bool EnableNonApprovalRequiredFunctionBypassing { get; set; }
/// <summary>
/// Creates a new instance of <see cref="ChatClientAgentOptions"/> with the same values as this instance.
/// </summary>
@@ -199,5 +229,6 @@ public sealed class ChatClientAgentOptions
ThrowOnChatHistoryProviderConflict = this.ThrowOnChatHistoryProviderConflict,
RequirePerServiceCallChatHistoryPersistence = this.RequirePerServiceCallChatHistoryPersistence,
EnableMessageInjection = this.EnableMessageInjection,
EnableNonApprovalRequiredFunctionBypassing = this.EnableNonApprovalRequiredFunctionBypassing,
};
}
@@ -148,4 +148,35 @@ public static class ChatClientBuilderExtensions
{
return builder.Use(innerClient => new MessageInjectingChatClient(innerClient));
}
/// <summary>
/// Adds an <see cref="NonApprovalRequiredFunctionBypassingChatClient"/> to the chat client pipeline.
/// </summary>
/// <remarks>
/// <para>
/// This decorator should be positioned above the <see cref="FunctionInvokingChatClient"/> in the pipeline
/// so that it can intercept approval requests for tools that do not require approval. When
/// <see cref="FunctionInvokingChatClient"/> converts all function calls to approval requests (because at
/// least one tool requires approval), this decorator removes the requests for non-approval-required tools,
/// stores them in the session, and automatically re-injects them as approved on the next request.
/// </para>
/// <para>
/// This extension method is intended for use with custom chat client stacks when
/// <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="true"/>.
/// When <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="false"/> (the default),
/// the <see cref="ChatClientAgent"/> automatically injects this decorator when
/// <see cref="ChatClientAgentOptions.EnableNonApprovalRequiredFunctionBypassing"/> is <see langword="true"/>.
/// </para>
/// <para>
/// This decorator only works within the context of a running <see cref="ChatClientAgent"/> with
/// an active session, and will throw an exception if used in any other stack.
/// </para>
/// </remarks>
/// <param name="builder">The <see cref="ChatClientBuilder"/> to add the decorator to.</param>
/// <returns>The <paramref name="builder"/> for chaining.</returns>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public static ChatClientBuilder UseNonApprovalRequiredFunctionBypassing(this ChatClientBuilder builder)
{
return builder.Use(innerClient => new NonApprovalRequiredFunctionBypassingChatClient(innerClient));
}
}
@@ -53,6 +53,17 @@ public static class ChatClientExtensions
{
var chatBuilder = chatClient.AsBuilder();
// NonApprovalRequiredFunctionBypassingChatClient is registered before FunctionInvokingChatClient so that
// it sits above FICC in the pipeline. ChatClientBuilder.Build applies factories in reverse order,
// making the first Use() call outermost. By adding this decorator first, the resulting pipeline is:
// NonApprovalRequiredFunctionBypassingChatClient → FunctionInvokingChatClient → ChatHistoryPersistingChatClient → leaf IChatClient
// This allows the decorator to intercept FICC's responses and remove approval requests for tools
// that don't actually require approval, storing them for automatic re-injection on the next request.
if (options?.EnableNonApprovalRequiredFunctionBypassing is true)
{
chatBuilder.Use(innerClient => new NonApprovalRequiredFunctionBypassingChatClient(innerClient));
}
if (chatClient.GetService<FunctionInvokingChatClient>() is null)
{
chatBuilder.Use((innerClient, services) =>
@@ -0,0 +1,285 @@
// 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;
/// <summary>
/// A delegating chat client that automatically removes <see cref="ToolApprovalRequestContent"/> for tools
/// that do not actually require approval, storing auto-approved results in the session for transparent
/// re-injection on the next request.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="FunctionInvokingChatClient"/> has an all-or-nothing behavior for approvals: when any tool
/// in a response is an <see cref="ApprovalRequiredAIFunction"/>, it converts all <see cref="FunctionCallContent"/>
/// items to <see cref="ToolApprovalRequestContent"/> — even for tools that do not require approval. This
/// decorator sits above <see cref="FunctionInvokingChatClient"/> in the pipeline and transparently handles
/// the non-approval-required items so callers only see approval requests for tools that truly need them.
/// </para>
/// <para>
/// On outbound responses, the decorator identifies <see cref="ToolApprovalRequestContent"/> items for tools
/// that are not wrapped in <see cref="ApprovalRequiredAIFunction"/>, removes them from the response, and
/// stores them in the session's <see cref="AgentSessionStateBag"/>. On the next inbound request, the stored
/// items are re-injected as pre-approved <see cref="ToolApprovalResponseContent"/> so that
/// <see cref="FunctionInvokingChatClient"/> can process them alongside the caller's human-approved responses.
/// </para>
/// <para>
/// This decorator requires an active <see cref="AIAgent.CurrentRunContext"/> with a non-null
/// <see cref="AgentRunContext.Session"/>. An <see cref="InvalidOperationException"/> is thrown if no
/// run context or session is available.
/// </para>
/// </remarks>
internal sealed class NonApprovalRequiredFunctionBypassingChatClient : DelegatingChatClient
{
/// <summary>
/// The key used in <see cref="AgentSessionStateBag"/> to store pending auto-approved function calls
/// between agent runs.
/// </summary>
internal const string StateBagKey = "_autoApprovedFunctionCalls";
/// <summary>
/// Initializes a new instance of the <see cref="NonApprovalRequiredFunctionBypassingChatClient"/> class.
/// </summary>
/// <param name="innerClient">The underlying chat client (typically a <see cref="FunctionInvokingChatClient"/>).</param>
public NonApprovalRequiredFunctionBypassingChatClient(IChatClient innerClient)
: base(innerClient)
{
}
/// <inheritdoc/>
public override async Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> 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;
}
/// <inheritdoc/>
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var session = GetRequiredSession();
var autoApprovableNames = this.GetAutoApprovableToolNames(options);
messages = InjectPendingAutoApprovals(messages, session);
List<ToolApprovalRequestContent>? 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);
}
}
}
/// <summary>
/// Gets the current <see cref="AgentSession"/> from the ambient run context.
/// </summary>
/// <exception cref="InvalidOperationException">No run context or session is available.</exception>
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.");
}
/// <summary>
/// Checks the session for stored auto-approvals from a previous turn and injects them as
/// a user message containing <see cref="ToolApprovalResponseContent"/> items appended to the input messages.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private static IEnumerable<ChatMessage> InjectPendingAutoApprovals(
IEnumerable<ChatMessage> messages,
AgentSession session)
{
if (!session.StateBag.TryGetValue<List<ToolApprovalRequestContent>>(
StateBagKey,
out var pendingRequests,
AgentJsonUtilities.DefaultOptions)
|| pendingRequests is not { Count: > 0 })
{
return messages;
}
session.StateBag.TryRemoveValue(StateBagKey);
List<AIContent> approvalResponses = [];
foreach (var request in pendingRequests)
{
approvalResponses.Add(request.CreateResponse(approved: true));
}
var userMessage = new ChatMessage(ChatRole.User, approvalResponses);
return messages.Concat([userMessage]);
}
/// <summary>
/// Builds a set of tool names that do not require approval and can be auto-approved,
/// by checking all available tools from <see cref="ChatOptions.Tools"/> and
/// <see cref="FunctionInvokingChatClient.AdditionalTools"/>.
/// </summary>
private HashSet<string> GetAutoApprovableToolNames(ChatOptions? options)
{
var ficc = this.GetService<FunctionInvokingChatClient>();
var allTools = (options?.Tools ?? Enumerable.Empty<AITool>())
.Concat(ficc?.AdditionalTools ?? Enumerable.Empty<AITool>());
return new HashSet<string>(
allTools
.OfType<AIFunction>()
.Where(static f => f.GetService<ApprovalRequiredAIFunction>() is null)
.Select(static f => f.Name),
StringComparer.Ordinal);
}
/// <summary>
/// Determines whether a <see cref="ToolApprovalRequestContent"/> can be auto-approved because
/// the underlying tool is not an <see cref="ApprovalRequiredAIFunction"/>.
/// </summary>
/// <returns>
/// <see langword="true"/> if the approval request is for a known tool that does not require approval
/// and can be auto-approved; <see langword="false"/> otherwise.
/// </returns>
private static bool IsAutoApprovable(ToolApprovalRequestContent approval, HashSet<string> 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);
}
/// <summary>
/// Scans response messages for auto-approvable <see cref="ToolApprovalRequestContent"/> items,
/// removes them from the messages, and stores them in the session for the next request.
/// </summary>
private static void RemoveAutoApprovedFromMessages(
IList<ChatMessage> messages,
HashSet<string> autoApprovableNames,
AgentSession session)
{
List<ToolApprovalRequestContent>? 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);
}
}
/// <summary>
/// Filters auto-approvable <see cref="ToolApprovalRequestContent"/> items from a streaming update's
/// contents, collecting them for later storage.
/// </summary>
/// <returns>
/// <see langword="true"/> if the update should be yielded (has remaining content or had no
/// approval content to begin with); <see langword="false"/> if the update is now empty and
/// should be skipped.
/// </returns>
private static bool FilterUpdateContents(
ChatResponseUpdate update,
HashSet<string> autoApprovableNames,
ref List<ToolApprovalRequestContent>? autoApproved)
{
bool hasApprovalContent = false;
List<AIContent> 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;
}
}