Skip to content

.NET: [Bug]: Order inversion in FunctionCallMiddleware due to ChatClientFactory interaction #6260

@helloxubo

Description

@helloxubo

Description

The order of function-call middleware registered at the agent level appears inverted at runtime. When two FunctionInvocationDelegatingAgent middlewares (A then B) are registered via AIAgentBuilder.Use, the built agent is logically A(B(inner)). However, the observed runtime invocation order for function calls is B.Pre -> A.Pre -> function -> A.Post -> B.Post, i.e. B appears to be the outer middleware.
Reproduction steps

  1. Register two function invocation middlewares in this order:
    • builder.Use(FunctionCallMiddlewareA) // A
    • builder.Use(FunctionCallMiddlewareB) // B
  2. Build the agent and invoke a function (e.g., GetDateTime).
  3. Observe console/log output showing middleware pre/post messages.
    Observed output (example)
    • Function Name: GetDateTime - Middleware B Pre-Invoke
    • Function Name: GetDateTime - Middleware A Pre-Invoke
    • Function Name: GetDateTime - Middleware A Post-Invoke
    • Function Name: GetDateTime - Middleware B Post-Invoke
    Expected behavior Registering Use(A) then Use(B) should produce A(B(inner)) both at agent-level wrapping and at function-invocation time, so invocation order should be:
    • A.Pre -> B.Pre -> function -> B.Post -> A.Post

Code Sample

c#
chatClient = chatClient.AsBuilder()
    .Use(getResponseFunc: ChatClientMiddlewareA, getStreamingResponseFunc: null)
    .Use(getResponseFunc: ChatClientMiddlewareB, getStreamingResponseFunc: null)
    .ConfigureOptions(options =>
    {
        options.RawRepresentationFactory = (response) =>
        {
            var opt = new OpenAI.Chat.ChatCompletionOptions();
#pragma warning disable SCME0001 // 类型仅用于评估,在将来的更新中可能会被更改或删除。取消此诊断以继续。
            opt.Patch.Set("$thinking.type"u8, "disabled");
#pragma warning restore SCME0001 // 类型仅用于评估,在将来的更新中可能会被更改或删除。取消此诊断以继续。
            return opt;
        };
    })
    .Build();

var originalAgent = chatClient
    .AsBuilder()
    .BuildAIAgent(
        instructions: "You are an AI assistant that helps people find information.",
        tools: [AIFunctionFactory.Create(GetDateTime, name: nameof(GetDateTime))]);

var middlewareAgent = originalAgent
    .AsBuilder()
    .Use(FunctionCallMiddlewareA)
    .Use(FunctionCallMiddlewareB)
    .Build();

var response = await middlewareAgent.RunAsync("Hello, what time is it?", cancellationToken: default);


async Task<ChatResponse> ChatClientMiddlewareA(IEnumerable<ChatMessage> message, ChatOptions? options, IChatClient innerChatClient, CancellationToken cancellationToken)
{
    Console.WriteLine("Chat Client Middleware A - Pre-Chat");
    var response = await innerChatClient.GetResponseAsync(message, options, cancellationToken);
    Console.WriteLine("Chat Client Middleware A - Post-Chat");

    return response;
}

async Task<ChatResponse> ChatClientMiddlewareB(IEnumerable<ChatMessage> message, ChatOptions? options, IChatClient innerChatClient, CancellationToken cancellationToken)
{
    Console.WriteLine("Chat Client Middleware B - Pre-Chat");
    var response = await innerChatClient.GetResponseAsync(message, options, cancellationToken);
    Console.WriteLine("Chat Client Middleware B - Post-Chat");

    return response;
}

async ValueTask<object?> FunctionCallMiddlewareA(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
    Console.WriteLine($"Function Name: {context!.Function.Name} - Middleware A Pre-Invoke");
    var result = await next(context, cancellationToken);
    Console.WriteLine($"Function Name: {context!.Function.Name} - Middleware A Post-Invoke");

    return result;
}

async ValueTask<object?> FunctionCallMiddlewareB(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
    Console.WriteLine($"Function Name: {context!.Function.Name} - Middleware B Pre-Invoke");
    var result = await next(context, cancellationToken);
    Console.WriteLine($"Function Name: {context!.Function.Name} - Middleware B Post-Invoke");

    return result;
}

[Description("The current datetime offset.")]
static string GetDateTime()
    => DateTimeOffset.Now.ToString();

Error Messages / Stack Traces

Package Versions

MicroSoft.Agents.AI 1.8.0

.NET Version

.Net 10.0

Additional Context

Root cause hypothesis
• FunctionInvocationDelegatingAgent injects its function-middleware by setting ChatClientAgentRunOptions.ChatClientFactory inside AgentRunOptionsWithFunctionMiddleware.
• The ChatClientFactory implementation constructs a temporary chat-client builder and performs builder.Use(originalFactory) then builder.ConfigureOptions(...) (which registers the middleware that wraps functions).
• Because chat-client builders also append factories and Build() applies them in reverse, the order of registrations inside the ChatClientFactory causes the chat-client-level composition to produce the opposite outer/inner relationship.
• In short: agent-level wrapping + per-request chat-client factory registration (which itself registers factories) interact such that registration order ends up inverted at runtime.
Relevant files
• src/Microsoft.Agents.AI/FunctionInvocationDelegatingAgent.cs (method: AgentRunOptionsWithFunctionMiddleware)
• src/Microsoft.Agents.AI/AIAgentBuilder.cs (Use / Build semantics)
• src/Microsoft.Agents.AI/ChatClient/ChatClientAgentRunOptions.cs (ChatClientFactory)

Metadata

Metadata

Assignees

No one assigned

    Type

    No fields configured for Bug.

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions