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
- Set up an AGUI-hosted agent with both
clientTools (frontend-executed tools) and an AgentSkillsProvider (which registers server-side tools like load_skill)
- Send a user message that triggers the LLM to call both tools in one response
- Frontend receives only the frontend tool call (server tools filtered by
FilterServerToolsFromMixedToolInvocationsAsync), executes it, sends result back
- Backend loads chat history (original unfiltered assistant message) + new tool result → sends to LLM
- 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):
-
In ChatHistoryProvider: persist the filtered response instead of the raw LLM response. This requires passing filter information into the agent pipeline.
-
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.
-
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
Description
Describe the bug
When a LLM response contains both frontend tools (
clientTools) and server-side tools (e.g., tools registered byAgentSkillsProvider) in a singleassistantmessage, the pipeline filters server-side tools from the SSE event stream sent to the client. However, the original unfiltered response has already been persisted byChatHistoryProvider.StoreChatHistoryAsync(which runs insideRunStreamingAsync, 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
clientTools(frontend-executed tools) and anAgentSkillsProvider(which registers server-side tools likeload_skill)FilterServerToolsFromMixedToolInvocationsAsync), executes it, sends result backRoot cause
Timing mismatch in the AGUI pipeline:
RunStreamingAsync (inside AIHostAgent)
└─ ChatHistoryProvider.StoreChatHistoryAsync() ← stores original unfiltered response
↓
AsChatResponseUpdatesAsync()
└─ FilterServerToolsFromMixedToolInvocationsAsync() ← filters after storage, too late
↓
AsAGUIEventStreamAsync()
ChatHistoryProviderlifecycle runs inside the agent pipeline before the AGUI extension methods that filter tools. No coordination mechanism exists between the two layers.Environment
Microsoft.Agents.AI.Hosting.AGUI.AspNetCoreversion 1.10.0-preview.260610.1Microsoft.Agents.AI.OpenAIversion 1.10.0Suggested fix
The fix should happen at one of these levels (listed by preference):
In
ChatHistoryProvider: persist the filtered response instead of the raw LLM response. This requires passing filter information into the agent pipeline.In
FilterServerToolsFromMixedToolInvocationsAsync: before filtering, inject a no-opFunctionResultContentfor each removed server-side tool call, so the tool_call/tool_result pairing remains valid in history.In the message reconstruction path: before sending history to the LLM, scan for orphaned
FunctionCallContententries that lack correspondingFunctionResultContent, 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
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