Skip to content

.NET: [Bug]: FilterServerToolsFromMixedToolInvocationsAsync causes HTTP 400 when LLM returns mixed frontend + server-side tool calls in AGUI hosted mode #6967

Description

@Jun-GY

Description

Describe the bug

When a LLM response contains both frontend tools (clientTools) and server-side tools (e.g., tools registered by AgentSkillsProvider) in a single assistant message, the pipeline filters server-side tools from the SSE event stream sent to the client. However, the original unfiltered response has already been persisted by ChatHistoryProvider.StoreChatHistoryAsync (which runs inside RunStreamingAsync, before the filtering extension method).

On the next turn, the reconstructed message history contains:

assistant: [FunctionCall(frontendTool), FunctionCall(serverSideTool)]
tool: [FunctionResult(frontendTool)]

OpenAI-compatible APIs reject this with HTTP 400:

invalid_request_error: An assistant message with 'tool_calls' must be followed
by tool messages responding to each 'tool_call_id'.
(insufficient tool messages following tool_calls message)

To Reproduce

  1. Set up an AGUI-hosted agent with both clientTools (frontend-executed tools) and an AgentSkillsProvider (which registers server-side tools like load_skill)
  2. Send a user message that triggers the LLM to call both tools in one response
  3. Frontend receives only the frontend tool call (server tools filtered by FilterServerToolsFromMixedToolInvocationsAsync), executes it, sends result back
  4. Backend loads chat history (original unfiltered assistant message) + new tool result → sends to LLM
  5. LLM API returns 400: server-side tool call has no corresponding tool result

Root cause

Timing mismatch in the AGUI pipeline:

RunStreamingAsync (inside AIHostAgent)
└─ ChatHistoryProvider.StoreChatHistoryAsync() ← stores original unfiltered response

AsChatResponseUpdatesAsync()
└─ FilterServerToolsFromMixedToolInvocationsAsync() ← filters after storage, too late

AsAGUIEventStreamAsync()

ChatHistoryProvider lifecycle runs inside the agent pipeline before the AGUI extension methods that filter tools. No coordination mechanism exists between the two layers.

Environment

  • Package: Microsoft.Agents.AI.Hosting.AGUI.AspNetCore version 1.10.0-preview.260610.1
  • Package: Microsoft.Agents.AI.OpenAI version 1.10.0
  • LLM: any OpenAI-compatible API

Suggested fix

The fix should happen at one of these levels (listed by preference):

  1. In ChatHistoryProvider: persist the filtered response instead of the raw LLM response. This requires passing filter information into the agent pipeline.

  2. In FilterServerToolsFromMixedToolInvocationsAsync: before filtering, inject a no-op FunctionResultContent for each removed server-side tool call, so the tool_call/tool_result pairing remains valid in history.

  3. In the message reconstruction path: before sending history to the LLM, scan for orphaned FunctionCallContent entries that lack corresponding FunctionResultContent, and remove them from the assistant message.

Code Sample

AIController:

    [Controller]
    public class AIController : ControllerBase
    {
        private readonly IServiceProvider _serviceProvider;
        private readonly IAgentService _service;

        public AIController(IServiceProvider serviceProvider, IAgentService service)
        {
            this._serviceProvider = serviceProvider;
            this._service = service;
        }
[HttpPost]
[Route("chat")]
public async Task<IResult> ChatAsync([FromBody] RunAgentInput? input, CancellationToken cancellationToken)
{
    if (input is null)
    {
        return Results.BadRequest();
    }

    HttpContext.Request.Headers.TryGetValue("X-Agent", out var agentId);

    var agentName = agentId.ToString().Trim() ?? "mainAgent";

    var sessionStore = _serviceProvider.GetService<MyAgentSessionStore>();

    var isolationKeyProvider = _serviceProvider.GetService<SessionIsolationKeyProvider>();

    var agentSessionStore = new IsolationKeyScopedAgentSessionStore( //_serviceProvider.GetKeyedService<AgentSessionStore>(agentName);
        sessionStore, 
        isolationKeyProvider, 
        new() { Strict = isolationKeyProvider != null });

    // Ensure that we have an IsolationKeyScopedAgentSessionStore registered.
    //var isolationKeyProvider = _serviceProvider.GetService<SessionIsolationKeyProvider>();
    //if (agentSessionStore?.GetService<IsolationKeyScopedAgentSessionStore>() is null)
    //{
    //    agentSessionStore ??= new NoopAgentSessionStore();
    //    agentSessionStore = new IsolationKeyScopedAgentSessionStore(
    //        agentSessionStore, isolationKeyProvider, new() { Strict = isolationKeyProvider != null });
    //}

    var agentOptions = new CreateAgentOptions
    {
        BaseUrl = HttpContext.Request.Headers.TryGetValue("X-BaseUrl", out var baseUrl) ? baseUrl.ToString().Trim() : throw new InvalidOperationException("Base URL is required"),
        Model = HttpContext.Request.Headers.TryGetValue("X-Model", out var model) ? model.ToString().Trim() : throw new InvalidOperationException("Model is required"),
        ApiKey = HttpContext.Request.Headers.TryGetValue("X-ApiKey", out var apiKey) ? apiKey.ToString().Trim() : throw new InvalidOperationException("API key is required"),
        Agent = agentName,
        SkillDirs = HttpContext.Request.Headers.TryGetValue("X-SkillDirs", out var skillDirs)
            ? skillDirs.ToString().Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
            : []
    };


    var clientTools = input.Tools?.AsAITools().ToList();

    agentOptions.Tools = clientTools?.ToArray() ?? [];

    agentOptions.SkillDirs = ["D:\\Workspace\\Dotnet\\Projects\\MAFLearning\\AG-UI\\AIService\\assets\\skills\\data-search"];

    var agent = _service.CreateAgent(agentOptions);

    //var agent = _builder.Build(agentOptions);

    var hostAgent = new AIHostAgent(agent, agentSessionStore);

    var jsonOptions = HttpContext.RequestServices.GetRequiredService<IOptions<Microsoft.AspNetCore.Http.Json.JsonOptions>>();
    var jsonSerializerOptions = jsonOptions.Value.SerializerOptions;

    var messages = input.Messages.AsChatMessages(jsonSerializerOptions);

    // Create run options with AG-UI context in AdditionalProperties
    var runOptions = new ChatClientAgentRunOptions
    {
        ChatOptions = new ChatOptions
        {
            AdditionalProperties = new AdditionalPropertiesDictionary
            {
                ["ag_ui_state"] = input.State,
                ["ag_ui_context"] = input.Context?.Select(c => new KeyValuePair<string, string>(c.Description, c.Value)).ToArray(),
                ["ag_ui_forwarded_properties"] = input.ForwardedProperties,
                ["ag_ui_thread_id"] = input.ThreadId,
                ["ag_ui_run_id"] = input.RunId
            }
        }
    };

    var threadId = string.IsNullOrWhiteSpace(input.ThreadId) ? Guid.NewGuid().ToString("N") : input.ThreadId;

    var session = await hostAgent.GetOrCreateSessionAsync(threadId, cancellationToken).ConfigureAwait(false);

    // Run the agent and convert to AG-UI events
    var events = hostAgent.RunStreamingAsync(
        messages,
        session: session,
        options: runOptions,
        cancellationToken: cancellationToken)
        .AsChatResponseUpdatesAsync()
        .FilterServerToolsFromMixedToolInvocationsAsync(clientTools, cancellationToken)
        .AsAGUIEventStreamAsync(
            threadId,
            input.RunId,
            jsonSerializerOptions,
            cancellationToken);

    // Wrap the event stream to save the session after streaming completes
    var eventsWithSessionSave = SaveSessionAfterStreamingAsync(events, hostAgent, threadId, session, cancellationToken);

    var sseLogger = HttpContext.RequestServices.GetRequiredService<ILogger<AGUIServerSentEventsResult>>();

    return new AGUIServerSentEventsResult(eventsWithSessionSave, sseLogger);
}

private static async IAsyncEnumerable<BaseEvent> SaveSessionAfterStreamingAsync(
    IAsyncEnumerable<BaseEvent> events,
    AIHostAgent hostAgent,
    string threadId,
    AgentSession session,
    [EnumeratorCancellation] CancellationToken cancellationToken)
{
    await foreach (BaseEvent evt in events.ConfigureAwait(false))
    {
        yield return evt;
    }

    await hostAgent.SaveSessionAsync(threadId, session, cancellationToken).ConfigureAwait(false);
}
}



AgentService:

 public class AgentService : IAgentService
{
private readonly IServiceProvider _serviceProvider;
 public AgentService(IServiceProvider serviceProvider)
 {
     _serviceProvider = serviceProvider;
 }
        public AIAgent CreateAgent(CreateAgentOptions options)
        {
            var metadata = _metadataService.GetAgentMetadata(options.Agent);

            var client = new OpenAIClient(
                new ApiKeyCredential(options.ApiKey),
                new OpenAIClientOptions { Endpoint = new Uri(options.BaseUrl) });

            var chatClient = client.GetChatClient(options.Model).AsIChatClient();

            var chatHistoryProvider = _serviceProvider.GetRequiredService<InMemoryChatHistoryProvider>();

#pragma warning disable MAAI001 // 类型仅用于评估,在将来的更新中可能会被更改或删除。取消此诊断以继续。
            var agentOptions = new ChatClientAgentOptions
            {
                Name = metadata?.Name ?? options.Agent,
                Description = metadata?.Description,
                RequirePerServiceCallChatHistoryPersistence = true,
                ChatHistoryProvider = chatHistoryProvider,
                ChatOptions = new ChatOptions
                {
                    Tools = options.Tools,
                    Instructions = metadata?.Instructions,
                    RawRepresentationFactory = this.DisabledDeepSeekThinking,
                },
            };

            if (options.SkillDirs.Length > 0)
            {
                var skill = new AgentSkillsProvider(
                    options.SkillDirs,
                    fileOptions: new AgentFileSkillsSourceOptions() { SearchDepth = 10, ResourceFilter = x => true });

                agentOptions.AIContextProviders = [skill];
            }

            var agent = chatClient
                .AsAIAgent(agentOptions)
                .AsBuilder()
                .UseOpenTelemetry(null, o => o.EnableSensitiveData = true)
                .Build();

            return agent;
        }

        private ChatCompletionOptions DisabledDeepSeekThinking(IChatClient client)
        {
            var o = new ChatCompletionOptions();

#pragma warning disable SCME0001 // 类型仅用于评估,在将来的更新中可能会被更改或删除。取消此诊断以继续。
            o.Patch.Set("$.thinking.type"u8, "disabled");
#pragma warning restore SCME0001 // 类型仅用于评估,在将来的更新中可能会被更改或删除。取消此诊断以继续。

            return o;
        }
}


MyAgentSessionStore:

    public class MyAgentSessionStore : AgentSessionStore
    {
        private readonly ConcurrentDictionary<string, JsonElement> _threads = new();

        public override async ValueTask<AgentSession> GetSessionAsync(
            AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
        {
            var key = agent.Name;
            JsonElement? sessionContent = this._threads.TryGetValue(key, out var existingSession) ? existingSession : null;

            return sessionContent switch
            {
                null => await agent.CreateSessionAsync(cancellationToken),
                _ => await agent.DeserializeSessionAsync(sessionContent.Value, cancellationToken: cancellationToken),
            };           
        }

        public override async ValueTask SaveSessionAsync(
            AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
        {
            var key = agent.Name;

            this._threads[key] = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken);
        }
    }

Error Messages / Stack Traces

HTTP 400 (invalid_request_error: invalid_request_error)

An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message)

Package Versions

Microsoft.Agents.AI.OpenAI: 1.10.0,Microsoft.Agents.AI.Hosting.AGUI.AspNetCore:1.10.0-preview.260610.1

.NET Version

.Net 10

Additional Context

No response

Metadata

Metadata

Assignees

Labels

.NETUsage: [Issues, PRs], Target: .Netag-uiUsage: [Issues, PRs], Target: AG-UI protocol integrationreproducedUsage: [Issues], Target: all issues that can be reproduced by the triage workflow

Type

Projects

Status
No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions