diff --git a/dotnet/src/Microsoft.Agents.AI/FunctionInvocationDelegatingAgent.cs b/dotnet/src/Microsoft.Agents.AI/FunctionInvocationDelegatingAgent.cs
index 13f15ff1e9f..5fdadda9264 100644
--- a/dotnet/src/Microsoft.Agents.AI/FunctionInvocationDelegatingAgent.cs
+++ b/dotnet/src/Microsoft.Agents.AI/FunctionInvocationDelegatingAgent.cs
@@ -2,7 +2,8 @@
using System;
using System.Collections.Generic;
-using System.Linq;
+using System.Collections.ObjectModel;
+using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -10,7 +11,7 @@
namespace Microsoft.Agents.AI;
///
-/// Internal agent decorator that adds function invocation middleware logic.
+/// Internal agent wrapper that gives callbacks control over function calls.
///
internal sealed class FunctionInvocationDelegatingAgent : DelegatingAIAgent
{
@@ -27,11 +28,12 @@ protected override Task RunCoreAsync(IEnumerable mes
protected override IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> this.InnerAgent.RunStreamingAsync(messages, session, this.AgentRunOptionsWithFunctionMiddleware(options), cancellationToken);
- // Decorate options to add the middleware function
- private AgentRunOptions? AgentRunOptionsWithFunctionMiddleware(AgentRunOptions? options)
+ // Work on a per-run copy so adding callback support does not change options that the caller may reuse.
+ private ChatClientAgentRunOptions AgentRunOptionsWithFunctionMiddleware(AgentRunOptions? options)
{
if (options is null || options.GetType() == typeof(AgentRunOptions))
{
+ // Plain agent options cannot hold a chat-client factory, so copy their shared values to chat-specific options.
options = new ChatClientAgentRunOptions()
{
ResponseFormat = options?.ResponseFormat,
@@ -40,6 +42,10 @@ protected override IAsyncEnumerable RunCoreStreamingAsync(I
AdditionalProperties = options?.AdditionalProperties,
};
}
+ else if (options is ChatClientAgentRunOptions chatOptions)
+ {
+ options = chatOptions.Clone();
+ }
if (options is not ChatClientAgentRunOptions aco)
{
@@ -47,42 +53,205 @@ protected override IAsyncEnumerable RunCoreStreamingAsync(I
}
var originalFactory = aco.ChatClientFactory;
- aco.ChatClientFactory = chatClient =>
+ // Apply the original factory before adding our wrapper so even a replacement client keeps these callbacks.
+ aco.ChatClientFactory = chatClient => FunctionMiddlewarePreservingChatClient.Build(chatClient, originalFactory, this);
+
+ return aco;
+ }
+
+ ///
+ /// Preserves the function middleware chain when tools are added or replaced during a run.
+ ///
+ private sealed class FunctionMiddlewarePreservingChatClient(
+ IChatClient innerClient, FunctionInvocationDelegatingAgent[] middlewareChain) : DelegatingChatClient(innerClient)
+ {
+ // Concurrent runs must not combine the callbacks collected while their clients are built.
+ private static readonly AsyncLocal s_buildScope = new();
+ private readonly FunctionInvocationDelegatingAgent[] _middlewareChain = middlewareChain;
+
+ internal static FunctionMiddlewarePreservingChatClient Build(
+ IChatClient chatClient, Func? originalFactory, FunctionInvocationDelegatingAgent middleware)
+ {
+ var previous = s_buildScope.Value;
+ // Nested agent wrappers build one client synchronously. Share their list only within the same run.
+ var scope = previous is not null && ReferenceEquals(previous.RunContext, CurrentRunContext)
+ ? previous
+ : new PipelineBuildScope(CurrentRunContext);
+ // Function wrapping reverses this list, so insert at the front to keep callbacks in registration order.
+ scope.Middleware.Insert(0, middleware);
+ s_buildScope.Value = scope;
+ try
+ {
+ var builder = chatClient.AsBuilder();
+ if (originalFactory is not null)
+ {
+ builder.Use(originalFactory);
+ }
+
+ return new FunctionMiddlewarePreservingChatClient(builder.Build(), [.. scope.Middleware]);
+ }
+ finally
+ {
+ // Restore the previous list so a later client build cannot reuse callbacks from this one.
+ s_buildScope.Value = previous;
+ }
+ }
+
+ public override async Task GetResponseAsync(
+ IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
+ => await this.InnerClient.GetResponseAsync(messages, this.ConfigureOptions(options), cancellationToken).ConfigureAwait(false);
+
+ public override async IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
- var builder = chatClient.AsBuilder();
+ await foreach (var update in this.InnerClient.GetStreamingResponseAsync(messages, this.ConfigureOptions(options), cancellationToken).ConfigureAwait(false))
+ {
+ yield return update;
+ }
+ }
+
+ private ChatOptions ConfigureOptions(ChatOptions? options)
+ {
+ // Each request gets its own options object, leaving caller-owned options unchanged.
+ options = options?.Clone() ?? new();
+ if (options.Tools is { } tools)
+ {
+ // This collection also wraps functions that are added or replaced later in the same run.
+ options.Tools = new MiddlewareEnabledTools(tools, this._middlewareChain);
+ }
+
+ return options;
+ }
+
+ private sealed class PipelineBuildScope(AgentRunContext? runContext)
+ {
+ internal AgentRunContext? RunContext { get; } = runContext;
+ internal List Middleware { get; } = [];
+ }
+ }
+
+ private sealed class MiddlewareEnabledTools : Collection
+ {
+ internal MiddlewareEnabledTools(IList tools, FunctionInvocationDelegatingAgent[] middleware)
+ {
+ this.MiddlewareChain = middleware;
+ // Add also wraps the functions already present, just as it will wrap functions added later.
+ foreach (var tool in tools)
+ {
+ this.Add(tool);
+ }
+ }
+
+ internal FunctionInvocationDelegatingAgent[] MiddlewareChain { get; }
- if (originalFactory is not null)
+ internal static void ApplyTo(ChatOptions options, FunctionInvocationDelegatingAgent[] middleware)
+ {
+ // Keep the current collection only when it already uses this exact callback list.
+ if (options.Tools is { } tools &&
+ (tools is not MiddlewareEnabledTools existing || !ReferenceEquals(existing.MiddlewareChain, middleware)))
{
- builder.Use(originalFactory);
+ options.Tools = new MiddlewareEnabledTools(tools, middleware);
}
+ }
- return builder.ConfigureOptions(co
- => co.Tools = co.Tools?.Select(tool => tool is AIFunction aiFunction
- ? new MiddlewareEnabledFunction(this.InnerAgent, aiFunction, this._delegateFunc)
- : tool)
- .ToList())
- .Build();
- };
+ // Route later additions and replacements through the same callback wrapping.
+ protected override void InsertItem(int index, AITool item) => base.InsertItem(index, this.Wrap(item));
- return options;
+ protected override void SetItem(int index, AITool item) => base.SetItem(index, this.Wrap(item));
+
+ private AITool Wrap(AITool tool)
+ {
+ if (tool is AIFunction function)
+ {
+ foreach (var middleware in this.MiddlewareChain)
+ {
+ function = MiddlewareEnabledFunction.Wrap(function, middleware, this.MiddlewareChain);
+ }
+
+ return function;
+ }
+
+ return tool;
+ }
}
- private sealed class MiddlewareEnabledFunction(AIAgent innerAgent, AIFunction innerFunction, Func>, CancellationToken, ValueTask