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
@@ -1,7 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;

namespace Microsoft.Agents.AI;

Expand Down Expand Up @@ -238,6 +240,41 @@ public sealed class ChatClientAgentOptions
/// </value>
public bool DisableApprovalResponseBinding { get; set; }

/// <summary>
/// Gets or sets a value indicating whether to enable bypassing that stores invocable (backend) function
/// calls in the session state and executes them on the next request when they are returned alongside
/// declaration-only (frontend) function calls in the same response.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="FunctionInvokingChatClient"/> terminates the function-calling loop as soon as it encounters
/// a non-invocable (declaration-only) <see cref="FunctionCallContent"/>, returning every
/// <see cref="FunctionCallContent"/> in that iteration — including invocable backend calls — to the caller
/// unexecuted. When the caller only resolves the declaration-only call (for example an AG-UI frontend
/// tool), the backend call's <c>call_id</c> is left orphaned, which causes the AI provider to reject the
/// next request.
/// </para>
/// <para>
/// When this property is set to <see langword="true"/>, an <see cref="ExecutableFunctionBypassingChatClient"/>
/// decorator is injected above <see cref="FunctionInvokingChatClient"/> in the pipeline. For responses that
/// contain both invocable and declaration-only function calls, the decorator removes the invocable calls,
/// stores them in the session, and returns only the declaration-only calls to the caller. On the next
/// request the stored calls are re-injected as pre-approved responses so
/// <see cref="FunctionInvokingChatClient"/> reconstructs and executes them.
/// </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="ExecutableFunctionBypassingChatClient"/>
/// manually via the <see cref="ChatClientBuilderExtensions.UseExecutableFunctionBypassing"/>
/// extension method.
/// </para>
/// </remarks>
/// <value>
/// Default is <see langword="false"/>.
/// </value>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public bool EnableExecutableFunctionBypassing { get; set; }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: Should the name be EnableExecutableFunctionDeferral or something like that? Basically, replacing bypassing with deferral as it seems the function isn't fully bypassed, but executed in the next request.


/// <summary>
/// Creates a new instance of <see cref="ChatClientAgentOptions"/> with the same values as this instance.
/// </summary>
Expand All @@ -258,5 +295,6 @@ public ChatClientAgentOptions Clone()
EnableMessageInjection = this.EnableMessageInjection,
DisableApprovalNotRequiredFunctionBypassing = this.DisableApprovalNotRequiredFunctionBypassing,
DisableApprovalResponseBinding = this.DisableApprovalResponseBinding,
EnableExecutableFunctionBypassing = this.EnableExecutableFunctionBypassing,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Agents.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;

namespace Microsoft.Extensions.AI;
Expand Down Expand Up @@ -221,4 +223,50 @@ public static ChatClientBuilder UseApprovalResponseBinding(this ChatClientBuilde
return builder.Use((innerClient, services) =>
new ApprovalResponseBindingChatClient(innerClient, loggerFactory ?? services.GetService<ILoggerFactory>()));
}

/// <summary>
/// Adds an <see cref="ExecutableFunctionBypassingChatClient"/> to the chat client pipeline.
/// </summary>
/// <remarks>
/// <para>
/// This decorator should be positioned above the <see cref="FunctionInvokingChatClient"/> in the pipeline.
/// When <see cref="FunctionInvokingChatClient"/> returns a response containing both an invocable (backend)
/// <see cref="FunctionCallContent"/> and a declaration-only (frontend) <see cref="FunctionCallContent"/> in
/// the same iteration, this decorator removes the invocable calls, stores them in the session, and returns
/// only the declaration-only calls to the caller. On the next request the stored calls are re-injected as
/// pre-approved responses so <see cref="FunctionInvokingChatClient"/> reconstructs and executes them.
/// </para>
/// <para>
/// If the pipeline also contains an <see cref="ApprovalResponseBindingChatClient"/>, this decorator must be
/// positioned <em>below</em> it. That client drops any <see cref="ToolApprovalResponseContent"/> that does not
/// correspond to a request it recorded, so that a forged approval cannot execute. The responses this decorator
/// injects are synthetic and have no such request, so placing the binding client below this decorator would
/// silently discard them and prevent the stored calls from ever executing.
/// </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.EnableExecutableFunctionBypassing"/> 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>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public static ChatClientBuilder UseExecutableFunctionBypassing(this ChatClientBuilder builder, ILoggerFactory? loggerFactory = null)
{
return builder.Use((innerClient, services) =>
new ExecutableFunctionBypassingChatClient(innerClient, loggerFactory ?? services.GetService<ILoggerFactory>()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ internal static IChatClient WithDefaultAgentMiddleware(this IChatClient chatClie
// 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 here, the resulting pipeline is:
// [ApprovalResponseBindingChatClient] → ApprovalNotRequiredFunctionBypassingChatClient → FunctionInvokingChatClient
// [ApprovalResponseBindingChatClient] → ApprovalNotRequiredFunctionBypassingChatClient → [ExecutableFunctionBypassingChatClient] → FunctionInvokingChatClient
// → [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.
Expand All @@ -78,6 +78,18 @@ internal static IChatClient WithDefaultAgentMiddleware(this IChatClient chatClie
new ApprovalNotRequiredFunctionBypassingChatClient(innerClient, services.GetService<ILoggerFactory>()));
}

// ExecutableFunctionBypassingChatClient is opt-in via EnableExecutableFunctionBypassing. It is
// registered after the approval decorators and immediately before FunctionInvokingChatClient, so it
// sits directly above FICC (ChatClientBuilder.Build applies factories in reverse order). It intercepts
// FICC responses that contain both invocable (backend) and declaration-only (frontend) function calls,
// removes the invocable calls, stores them in the session, and re-injects them as pre-approved
// responses on the next request so FICC reconstructs and executes them.
if (options?.EnableExecutableFunctionBypassing is true)
{
chatBuilder.Use((innerClient, services) =>
new ExecutableFunctionBypassingChatClient(innerClient, services.GetService<ILoggerFactory>()));
}

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