Skip to content

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,34 @@ public sealed class ChatClientAgentOptions
/// </value>
public bool DisableApprovalNotRequiredFunctionBypassing { get; set; }

/// <summary>
/// Gets or sets a value indicating whether to disable binding inbound tool-approval responses to the
/// model-originated approval requests that the framework surfaced.
/// </summary>
/// <remarks>
/// <para>
/// By default (when this property is <see langword="false"/>), an <see cref="ApprovalResponseBindingChatClient"/>
/// decorator is injected as the outermost decorator above <see cref="FunctionInvokingChatClient"/>. It records each
/// <see cref="ToolApprovalRequestContent"/> the framework surfaces and, on the next request, binds every
/// <see cref="ToolApprovalResponseContent"/> to its recorded request: the response's tool call is rebound to the
/// model-originated call, and only approvals tied to a genuine, framework-issued request take effect. This keeps an
/// approved call aligned with exactly what a human was asked to approve.
/// </para>
/// <para>
/// Set this property to <see langword="true"/> to disable this behavior. Keeping it enabled is recommended, as it
/// strengthens the human-in-the-loop approval control; disable it only when approval binding is enforced elsewhere.
/// </para>
/// <para>
/// This option has no effect when <see cref="UseProvidedChatClientAsIs"/> is <see langword="true"/>.
Comment thread
rogerbarreto marked this conversation as resolved.
/// When using a custom chat client stack, you can add an <see cref="ApprovalResponseBindingChatClient"/>
/// manually via the <see cref="ChatClientBuilderExtensions.UseApprovalResponseBinding"/> extension method.
/// </para>
/// </remarks>
/// <value>
/// Default is <see langword="false"/>.
/// </value>
public bool DisableApprovalResponseBinding { get; set; }

/// <summary>
/// Creates a new instance of <see cref="ChatClientAgentOptions"/> with the same values as this instance.
/// </summary>
Expand All @@ -229,5 +257,6 @@ public ChatClientAgentOptions Clone()
RequirePerServiceCallChatHistoryPersistence = this.RequirePerServiceCallChatHistoryPersistence,
EnableMessageInjection = this.EnableMessageInjection,
DisableApprovalNotRequiredFunctionBypassing = this.DisableApprovalNotRequiredFunctionBypassing,
DisableApprovalResponseBinding = this.DisableApprovalResponseBinding,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -182,4 +182,43 @@ public static ChatClientBuilder UseApprovalNotRequiredFunctionBypassing(this Cha
return builder.Use((innerClient, services) =>
new ApprovalNotRequiredFunctionBypassingChatClient(innerClient, loggerFactory ?? services.GetService<ILoggerFactory>()));
}

/// <summary>
/// Adds an <see cref="ApprovalResponseBindingChatClient"/> to the chat client pipeline.
/// </summary>
/// <remarks>
/// <para>
/// This decorator should be positioned as the outermost decorator, above the
/// <see cref="FunctionInvokingChatClient"/> in the pipeline, so that it can bind the caller's inbound
/// tool-approval responses to the model-originated approval requests the framework surfaced. It records each
/// <see cref="ToolApprovalRequestContent"/> emitted by the pipeline and, on the next request, rebinds every
/// <see cref="ToolApprovalResponseContent"/> to its recorded request while honoring only approvals tied to a
/// genuine, framework-issued request. This keeps an approved call aligned with exactly what a human was asked to
/// approve.
/// </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 unless
/// <see cref="ChatClientAgentOptions.DisableApprovalResponseBinding"/> is <see langword="true"/>.
/// </para>
/// <para>
/// This decorator is intended for use within the context of a running <see cref="ChatClientAgent"/> with
/// an active session. When invoked outside of an agent run (for example when the built chat client is used
/// directly), the decorator becomes a no-op, passing the request through unchanged and logging a warning.
/// </para>
/// </remarks>
/// <param name="builder">The <see cref="ChatClientBuilder"/> to add the decorator to.</param>
/// <param name="loggerFactory">
/// An optional <see cref="ILoggerFactory"/> used to create a logger for the decorator. When not provided,
/// the factory is resolved from the pipeline's <see cref="IServiceProvider"/>; if none is available,
/// logging is a no-op.
/// </param>
/// <returns>The <paramref name="builder"/> for chaining.</returns>
public static ChatClientBuilder UseApprovalResponseBinding(this ChatClientBuilder builder, ILoggerFactory? loggerFactory = null)
{
return builder.Use((innerClient, services) =>
new ApprovalResponseBindingChatClient(innerClient, loggerFactory ?? services.GetService<ILoggerFactory>()));
}
}
18 changes: 15 additions & 3 deletions dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,23 @@ internal static IChatClient WithDefaultAgentMiddleware(this IChatClient chatClie
{
var chatBuilder = chatClient.AsBuilder();

// ApprovalResponseBindingChatClient is registered first so that it sits as the outermost decorator,
// above ApprovalNotRequiredFunctionBypassingChatClient and FunctionInvokingChatClient. ChatClientBuilder.Build
// applies factories in reverse order, making the first Use() call outermost. Placing it outermost lets it
// inspect the caller's raw approval responses before any framework-generated (auto-approved) responses are
// injected below it, binding each response to the model-originated approval request the framework surfaced so
// an approved call matches exactly what was surfaced for approval.
if (options?.DisableApprovalResponseBinding is not true)
{
chatBuilder.Use((innerClient, services) =>
new ApprovalResponseBindingChatClient(innerClient, services.GetService<ILoggerFactory>()));
}

// ApprovalNotRequiredFunctionBypassingChatClient 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:
// ApprovalNotRequiredFunctionBypassingChatClientFunctionInvokingChatClient[MessageInjectingChatClient]
// → [PerServiceCallChatHistoryPersistingChatClient] → DeferredOpenTelemetryChatClient → leaf IChatClient
// making the first Use() call outermost. By adding this decorator here, the resulting pipeline is:
// [ApprovalResponseBindingChatClient]ApprovalNotRequiredFunctionBypassingChatClientFunctionInvokingChatClient
// → [MessageInjectingChatClient] → [PerServiceCallChatHistoryPersistingChatClient] → DeferredOpenTelemetryChatClient → 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?.DisableApprovalNotRequiredFunctionBypassing is not true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,10 @@ protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingA
// 5. Queue excess unapproved requests and yield only the first to the caller.
if (unapproved.Count > 1)
{
// Record every unapproved request as surfaced so the caller's responses can be bound to a
// model-originated request during the queue cycle.
RecordSurfacedApprovalRequests(state, unapproved);

state.QueuedApprovalRequests.AddRange(unapproved.GetRange(1, unapproved.Count - 1));
}

Expand All @@ -266,13 +270,25 @@ protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingA

/// <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.
/// and collects the ones bound to a request the harness surfaced into
/// <see cref="ToolApprovalState.CollectedApprovalResponses"/>.
/// Extracted responses are removed from the messages in-place. Only a response whose request id matches a
/// surfaced request is honored, and a matched response has its tool call rebound to the surfaced request's
/// tool call so an approved call matches exactly what was surfaced for approval.
/// </summary>
private static void CollectApprovalResponsesFromMessages(
List<ChatMessage> messages,
ToolApprovalState state)
{
// Index the requests the harness surfaced so responses can be bound to a model-originated request.
var surfacedByRequestId = new Dictionary<string, ToolApprovalRequestContent>(StringComparer.Ordinal);
foreach (var surfaced in state.SurfacedApprovalRequests)
Comment thread
rogerbarreto marked this conversation as resolved.
Outdated
{
surfacedByRequestId[surfaced.RequestId] = surfaced;
}

HashSet<string>? consumedRequestIds = null;
Comment thread
rogerbarreto marked this conversation as resolved.
Outdated

// Walk messages in reverse so we can safely remove by index.
for (int i = messages.Count - 1; i >= 0; i--)
{
Expand All @@ -294,13 +310,29 @@ private static void CollectApprovalResponsesFromMessages(
continue;
}

// Separate approval responses (→ state) from other content (→ keep in message).
// Separate bound approval responses (→ state) from other content (→ keep in message).
// Responses not tied to a surfaced request are not collected, so only genuine approvals take effect.
var remaining = new List<AIContent>(message.Contents.Count);
foreach (var content in message.Contents)
{
if (content is ToolApprovalResponseContent response)
{
state.CollectedApprovalResponses.Add(response);
// Remove on match so a duplicate response for the same request in this pass is
// honored only once.
if (surfacedByRequestId.TryGetValue(response.RequestId, out var surfacedRequest))
Comment thread
rogerbarreto marked this conversation as resolved.
Outdated
{
surfacedByRequestId.Remove(response.RequestId);

// Rebind to the surfaced request's tool call and record for injection.
state.CollectedApprovalResponses.Add(
new ToolApprovalResponseContent(response.RequestId, response.Approved, surfacedRequest.ToolCall)
{
Reason = response.Reason,
});
(consumedRequestIds ??= new(StringComparer.Ordinal)).Add(response.RequestId);
}

// Bound responses are collected above; either way the response is not kept in the message.
}
else
{
Expand All @@ -321,6 +353,53 @@ private static void CollectApprovalResponsesFromMessages(
messages[i] = cloned;
}
}

// Consume surfaced requests whose response has now been collected, so they cannot be replayed.
if (consumedRequestIds is { Count: > 0 })
{
state.SurfacedApprovalRequests.RemoveAll(r => consumedRequestIds.Contains(r.RequestId));
}
}

/// <summary>
/// Records the given approval requests as surfaced to the caller, de-duplicating by request id.
/// A snapshot of each request is stored so later mutation of the caller-visible instance cannot change
/// the recorded tool call used to bind the response.
/// </summary>
private static void RecordSurfacedApprovalRequests(ToolApprovalState state, IReadOnlyList<ToolApprovalRequestContent> requests)
{
var known = new HashSet<string>(StringComparer.Ordinal);
foreach (var surfaced in state.SurfacedApprovalRequests)
{
known.Add(surfaced.RequestId);
}
Comment thread
rogerbarreto marked this conversation as resolved.
Outdated

foreach (var request in requests)
{
if (known.Add(request.RequestId))
{
state.SurfacedApprovalRequests.Add(SnapshotRequest(request));
}
}
}

/// <summary>
/// Creates a snapshot of an approval request so a later mutation of the caller-visible instance
/// (for example changing the tool call arguments) cannot alter the recorded request used for binding.
/// </summary>
private static ToolApprovalRequestContent SnapshotRequest(ToolApprovalRequestContent request)
{
if (request.ToolCall is FunctionCallContent functionCall)
{
var clonedCall = new FunctionCallContent(
functionCall.CallId,
functionCall.Name,
functionCall.Arguments is null ? null : new Dictionary<string, object?>(functionCall.Arguments));

return new ToolApprovalRequestContent(request.RequestId, clonedCall);
}

return request;
}

/// <summary>
Expand Down Expand Up @@ -388,6 +467,8 @@ private async ValueTask DrainAutoApprovableFromQueueAsync(ToolApprovalState stat
}

// Queue fully resolved — caller should proceed to call the inner agent.
// Clear any surfaced requests left over from the completed queue cycle.
state.SurfacedApprovalRequests.Clear();
Comment thread
rogerbarreto marked this conversation as resolved.
Outdated
}

return (state, callerMessages, null);
Expand Down Expand Up @@ -485,10 +566,17 @@ private async ValueTask<bool> ProcessAndQueueOutboundApprovalRequestsAsync(
// 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.
for (int i = 1; i < unapproved.Count; i++)
if (unapproved.Count > 1)
{
toRemove.Add(unapproved[i]);
state.QueuedApprovalRequests.Add(unapproved[i]);
// Record every unapproved request as surfaced so the caller's responses can be bound to a
// model-originated request during the queue cycle.
RecordSurfacedApprovalRequests(state, unapproved);

for (int i = 1; i < unapproved.Count; i++)
{
toRemove.Add(unapproved[i]);
state.QueuedApprovalRequests.Add(unapproved[i]);
}
}
Comment thread
rogerbarreto marked this conversation as resolved.

// Walk messages in reverse and strip marked items.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,19 @@ internal sealed class ToolApprovalState
/// </remarks>
[JsonPropertyName("queuedApprovalRequests")]
public List<ToolApprovalRequestContent> QueuedApprovalRequests { get; set; } = new();

/// <summary>
/// Gets or sets the list of model-originated approval requests that the harness has surfaced to the caller
/// and is awaiting a response for.
/// </summary>
/// <remarks>
/// <para>
/// Used to bind inbound <see cref="ToolApprovalResponseContent"/> to a request the harness actually surfaced.
/// A response is honored only when its request id matches a surfaced request, and a matched response has its tool
/// call rebound to the surfaced request's tool call, so an approved call matches exactly what was surfaced for
/// approval. Entries are consumed once their response is collected.
/// </para>
/// </remarks>
[JsonPropertyName("surfacedApprovalRequests")]
public List<ToolApprovalRequestContent> SurfacedApprovalRequests { get; set; } = new();
Comment thread
rogerbarreto marked this conversation as resolved.
Outdated
}
Loading
Loading