Skip to content
9 changes: 9 additions & 0 deletions dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,15 @@ private static ChatClientAgent BuildInnerAgent(IChatClient chatClient, HarnessAg
// Build ChatClient stack
ChatClientBuilder chatClientBuilder = chatClient.AsBuilder();

// Registered first so it sits as the outermost decorator, above the approval-not-required bypassing
// and function invocation middleware, so it can bind inbound approval responses to the requests the
// framework surfaced. The harness uses UseProvidedChatClientAsIs, so this is added manually here rather
// than via the default ChatClientAgent pipeline.
if (options?.DisableApprovalResponseBinding is not true)
{
chatClientBuilder.UseApprovalResponseBinding();
}

if (options?.DisableApprovalNotRequiredFunctionBypassing is not true)
{
chatClientBuilder.UseApprovalNotRequiredFunctionBypassing();
Expand Down
13 changes: 13 additions & 0 deletions dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgentOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,19 @@ public sealed class HarnessAgentOptions
/// </remarks>
public bool DisableApprovalNotRequiredFunctionBypassing { get; set; }

/// <summary>
/// Gets or sets a value indicating whether binding inbound tool-approval responses to the
/// model-originated approval requests that the framework surfaced is disabled.
/// </summary>
/// <remarks>
/// When <see langword="false"/> (the default), the underlying chat client pipeline includes the decorator
/// added by <see cref="ChatClientBuilderExtensions.UseApprovalResponseBinding"/> as the outermost decorator
/// above the function invocation middleware. It records each surfaced approval request and, on the next
/// request, binds every approval response to its recorded request so an approved call matches exactly what
/// was surfaced for approval.
/// </remarks>
public bool DisableApprovalResponseBinding { get; set; }

/// <summary>
/// Gets or sets a value indicating whether the <see cref="FileMemoryProvider"/> is disabled.
/// </summary>
Expand Down

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 @@ -2,6 +2,7 @@

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
Expand Down Expand Up @@ -256,6 +257,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 @@ -267,13 +272,18 @@ 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)
{
var surfaced = state.SurfacedApprovalRequests;

// Walk messages in reverse so we can safely remove by index.
for (int i = messages.Count - 1; i >= 0; i--)
{
Expand All @@ -295,13 +305,28 @@ 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 matched request is consumed and a duplicate response for the
// same request in this pass is honored only once.
if (surfaced.TryGetValue(response.RequestId, out var surfacedRequest))
{
surfaced.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,
});
}

// Bound responses are collected above; either way the response is not kept in the message.
}
else
{
Expand All @@ -324,6 +349,40 @@ private static void CollectApprovalResponsesFromMessages(
}
}

/// <summary>
/// Records the given approval requests as surfaced to the caller, keyed 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)
{
// SurfacedApprovalRequests is empty here: this is called when a response comes back from the inner
// agent, which cannot happen while approval requests are outstanding.
foreach (var request in requests)
{
state.SurfacedApprovalRequests[request.RequestId] = 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>
/// Re-evaluates queued approval requests against current rules and auto-approval rules, and auto-approves any that now match.
/// </summary>
Expand Down Expand Up @@ -393,6 +452,9 @@ private async ValueTask DrainAutoApprovableFromQueueAsync(
}

// Queue fully resolved — caller should proceed to call the inner agent.
// Surfaced requests are consumed as their responses are collected in
// CollectApprovalResponsesFromMessages, so nothing should remain here.
Debug.Assert(state.SurfacedApprovalRequests.Count == 0, "Surfaced approval requests should be empty once the queue is resolved.");
}

return (state, callerMessages, null);
Expand Down Expand Up @@ -492,10 +554,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]);
}
}

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

/// <summary>
/// Gets or sets the model-originated approval requests that the harness has surfaced to the caller
/// and is awaiting a response for, keyed by request id.
/// </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 Dictionary<string, ToolApprovalRequestContent> SurfacedApprovalRequests { get; set; } = new();
}
Loading
Loading