Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 AutoApprovedFunctionRemovingChatClient : DelegatingChatClient
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I may suggest Bypassing as a better name to describe the intent here or did I got it wrong. Thoughts?

Suggested change
internal sealed class AutoApprovedFunctionRemovingChatClient : DelegatingChatClient
internal sealed class FunctionApprovalBypassingChatClient : 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="AutoApprovedFunctionRemovingChatClient"/> class.
/// </summary>
/// <param name="innerClient">The underlying chat client (typically a <see cref="FunctionInvokingChatClient"/>).</param>
public AutoApprovedFunctionRemovingChatClient(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, autoApprovableNames);

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, autoApprovableNames);
List<ToolApprovalRequestContent>? autoApproved = null;

await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false))
{
if (FilterUpdateContents(update, autoApprovableNames, ref autoApproved))
{
yield return update;
}
}

if (autoApproved is { Count: > 0 })
{
session.StateBag.SetValue(StateBagKey, autoApproved, AgentJsonUtilities.DefaultOptions);
Comment on lines +86 to +96
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In streaming mode the session state is only written after the async iterator completes. If the caller stops consuming the stream early (or cancels), this code path may never run, causing auto-approved ToolApprovalRequestContent items to be dropped (they’re filtered out of updates but never persisted for reinjection). Consider persisting incrementally as items are removed, or moving the SetValue into a try/finally so it executes when the iterator is disposed.

Suggested change
await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false))
{
if (FilterUpdateContents(update, autoApprovableNames, ref autoApproved))
{
yield return update;
}
}
if (autoApproved is { Count: > 0 })
{
session.StateBag.SetValue(StateBagKey, autoApproved, AgentJsonUtilities.DefaultOptions);
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);
}

Copilot uses AI. Check for mistakes.
}
}

/// <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(AutoApprovedFunctionRemovingChatClient)} 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(AutoApprovedFunctionRemovingChatClient)} 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>
private static IEnumerable<ChatMessage> InjectPendingAutoApprovals(
IEnumerable<ChatMessage> messages,
AgentSession session,
HashSet<string> autoApprovableNames)
{
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)
{
Comment on lines +135 to +139
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StateBagKey is removed from the session before confirming that any of the pending requests are still auto-approvable. If the tool set changes (or tool discovery fails) such that none match, the pending requests will be silently discarded. Consider only removing the key after at least one response is generated, or re-storing any non-injected requests so they aren’t lost.

Copilot uses AI. Check for mistakes.
if (IsAutoApprovable(request, autoApprovableNames))
{
approvalResponses.Add(request.CreateResponse(approved: true));
}
}

if (approvalResponses.Count == 0)
{
return messages;
}

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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,36 @@ public sealed class ChatClientAgentOptions
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public bool PersistChatHistoryAtEndOfRun { 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="AutoApprovedFunctionRemovingChatClient"/>
/// 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="AutoApprovedFunctionRemovingChatClient"/>
/// manually via the <see cref="ChatClientBuilderExtensions.UseAutoApprovedFunctionRemoval"/>
/// extension method.
/// </para>
/// </remarks>
/// <value>
/// Default is <see langword="false"/>.
/// </value>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public bool StoreAutoApprovedFunctionCalls { get; set; }
Comment on lines +171 to +172
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new option is wired into the default middleware pipeline and Clone(), but there are no unit tests asserting (1) the decorator is injected when StoreAutoApprovedFunctionCalls=true and (2) Clone() preserves the value (similar to existing PersistChatHistoryAtEndOfRun coverage). Adding those tests would help prevent regressions in option wiring.

Copilot uses AI. Check for mistakes.

/// <summary>
/// Creates a new instance of <see cref="ChatClientAgentOptions"/> with the same values as this instance.
/// </summary>
Expand All @@ -158,5 +188,6 @@ public ChatClientAgentOptions Clone()
WarnOnChatHistoryProviderConflict = this.WarnOnChatHistoryProviderConflict,
ThrowOnChatHistoryProviderConflict = this.ThrowOnChatHistoryProviderConflict,
PersistChatHistoryAtEndOfRun = this.PersistChatHistoryAtEndOfRun,
StoreAutoApprovedFunctionCalls = this.StoreAutoApprovedFunctionCalls,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -126,4 +126,35 @@ public static ChatClientBuilder UseChatHistoryPersisting(this ChatClientBuilder
{
return builder.Use(innerClient => new ChatHistoryPersistingChatClient(innerClient, markOnly));
}

/// <summary>
/// Adds an <see cref="AutoApprovedFunctionRemovingChatClient"/> 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.StoreAutoApprovedFunctionCalls"/> 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 UseAutoApprovedFunctionRemoval(this ChatClientBuilder builder)
{
return builder.Use(innerClient => new AutoApprovedFunctionRemovingChatClient(innerClient));
}
}
11 changes: 11 additions & 0 deletions dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ internal static IChatClient WithDefaultAgentMiddleware(this IChatClient chatClie
{
var chatBuilder = chatClient.AsBuilder();

// AutoApprovedFunctionRemovingChatClient 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:
// AutoApprovedFunctionRemovingChatClient → 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?.StoreAutoApprovedFunctionCalls is true)
{
chatBuilder.Use(innerClient => new AutoApprovedFunctionRemovingChatClient(innerClient));
}

if (chatClient.GetService<FunctionInvokingChatClient>() is null)
{
chatBuilder.Use((innerClient, services) =>
Expand Down
Loading
Loading