mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: fix parallel tool call rendering in AGUI translation layer (#6009)
Fix three interlocked bugs that prevent parallel tool calls from rendering correctly in AG-UI protocol clients: Bug #1: Scope synthetic MessageId fallback to text events only. The shared streamingMessageId was leaking into ToolCallStartEvent.ParentMessageId, causing all parallel tool calls to collapse into one FE card. Bug #2: Make ToolCallResultEvent.MessageId deterministically unique using result-{CallId} format. MEAI's FunctionInvokingChatClient batches all results with a shared MessageId, collapsing them in FE reconciliation. Bug #3: Coalesce consecutive assistant-tool-call messages in AsChatMessages. Once Bug #1 is fixed, the FE produces separate AGUIAssistantMessage per tool call. On multi-turn replay these become consecutive assistant messages without intervening tool results, triggering HTTP 400 from Azure OpenAI. Remove the now-dead ContainsToolResult helper introduced by PR #5800. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -20,8 +20,28 @@ internal static class AGUIChatMessageExtensions
|
||||
this IEnumerable<AGUIMessage> aguiMessages,
|
||||
JsonSerializerOptions jsonSerializerOptions)
|
||||
{
|
||||
// Coalesce consecutive AGUIAssistantMessages that carry tool_calls into a single
|
||||
// ChatMessage. The AG-UI client (e.g. @ag-ui/client) creates a separate assistant
|
||||
// message per tool call when ToolCallStartEvent.parentMessageId is empty, but
|
||||
// OpenAI's chat-completion API requires every assistant message with tool_calls
|
||||
// to be IMMEDIATELY followed by tool responses for each of its tool_call_ids.
|
||||
// Sending two consecutive single-tool-call assistant messages before any tool
|
||||
// result triggers HTTP 400 "tool_call_ids did not have response messages".
|
||||
List<AIContent>? pendingContents = null;
|
||||
string? pendingId = null;
|
||||
|
||||
foreach (var message in aguiMessages)
|
||||
{
|
||||
bool isAssistantWithToolCalls =
|
||||
message is AGUIAssistantMessage am && am.ToolCalls is { Length: > 0 };
|
||||
|
||||
if (pendingContents is not null && !isAssistantWithToolCalls)
|
||||
{
|
||||
yield return new ChatMessage(ChatRole.Assistant, pendingContents) { MessageId = pendingId };
|
||||
pendingContents = null;
|
||||
pendingId = null;
|
||||
}
|
||||
|
||||
var role = MapChatRole(message.Role);
|
||||
|
||||
switch (message)
|
||||
@@ -84,14 +104,14 @@ internal static class AGUIChatMessageExtensions
|
||||
|
||||
case AGUIAssistantMessage assistantMessage when assistantMessage.ToolCalls is { Length: > 0 }:
|
||||
{
|
||||
var contents = new List<AIContent>();
|
||||
pendingContents ??= new List<AIContent>();
|
||||
pendingId ??= message.Id;
|
||||
|
||||
if (!string.IsNullOrEmpty(assistantMessage.Content))
|
||||
{
|
||||
contents.Add(new TextContent(assistantMessage.Content));
|
||||
pendingContents.Add(new TextContent(assistantMessage.Content));
|
||||
}
|
||||
|
||||
// Add tool calls
|
||||
foreach (var toolCall in assistantMessage.ToolCalls)
|
||||
{
|
||||
Dictionary<string, object?>? arguments = null;
|
||||
@@ -102,16 +122,12 @@ internal static class AGUIChatMessageExtensions
|
||||
jsonSerializerOptions.GetTypeInfo(typeof(Dictionary<string, object?>)));
|
||||
}
|
||||
|
||||
contents.Add(new FunctionCallContent(
|
||||
pendingContents.Add(new FunctionCallContent(
|
||||
toolCall.Id,
|
||||
toolCall.Function.Name,
|
||||
arguments));
|
||||
}
|
||||
|
||||
yield return new ChatMessage(role, contents)
|
||||
{
|
||||
MessageId = message.Id
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -134,6 +150,12 @@ internal static class AGUIChatMessageExtensions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining pending assistant-tool-call entry at end of stream.
|
||||
if (pendingContents is not null)
|
||||
{
|
||||
yield return new ChatMessage(ChatRole.Assistant, pendingContents) { MessageId = pendingId };
|
||||
}
|
||||
}
|
||||
|
||||
public static IEnumerable<AGUIMessage> AsAGUIMessages(
|
||||
|
||||
@@ -448,24 +448,36 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
};
|
||||
|
||||
string? currentMessageId = null;
|
||||
string? streamingMessageId = null;
|
||||
string? textStreamingFallback = null;
|
||||
bool textInFallback = false;
|
||||
string? currentReasoningBaseId = null;
|
||||
string? currentReasoningId = null;
|
||||
string? currentReasoningMessageId = null;
|
||||
await foreach (var chatResponse in updates.WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
// Generate a fallback MessageId when the provider doesn't supply one.
|
||||
// This ensures all AGUI events have a valid messageId regardless of agent type.
|
||||
if (string.IsNullOrWhiteSpace(chatResponse.MessageId))
|
||||
// The text-event surface (TextMessageStart/Content/End) requires a non-empty
|
||||
// MessageId to be valid AGUI. Generate a fallback scoped to a contiguous run of
|
||||
// null/empty-MessageId chunks (one logical text message). Leave the raw
|
||||
// chatResponse.MessageId untouched so the tool-call surface below uses the raw
|
||||
// provider value — collapsing parallel tool calls under a synthetic shared parent
|
||||
// would make the FE render them as one assistant-message bubble instead of
|
||||
// distinct rows.
|
||||
string? textMessageId = chatResponse.MessageId;
|
||||
if (string.IsNullOrWhiteSpace(textMessageId))
|
||||
{
|
||||
chatResponse.MessageId = ContainsToolResult(chatResponse)
|
||||
? Guid.NewGuid().ToString("N")
|
||||
: (streamingMessageId ??= Guid.NewGuid().ToString("N"));
|
||||
textStreamingFallback ??= Guid.NewGuid().ToString("N");
|
||||
textMessageId = textStreamingFallback;
|
||||
textInFallback = true;
|
||||
}
|
||||
else if (textInFallback)
|
||||
{
|
||||
textStreamingFallback = null;
|
||||
textInFallback = false;
|
||||
}
|
||||
|
||||
if (chatResponse is { Contents.Count: > 0 } &&
|
||||
chatResponse.Contents[0] is TextContent &&
|
||||
!string.Equals(currentMessageId, chatResponse.MessageId, StringComparison.Ordinal))
|
||||
!string.Equals(currentMessageId, textMessageId, StringComparison.Ordinal))
|
||||
{
|
||||
// Close any open reasoning block before opening a text message, so AG-UI
|
||||
// events are properly bracketed. MEAI providers share one MessageId across
|
||||
@@ -498,11 +510,11 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
// Start the new message
|
||||
yield return new TextMessageStartEvent
|
||||
{
|
||||
MessageId = chatResponse.MessageId!,
|
||||
MessageId = textMessageId!,
|
||||
Role = chatResponse.Role!.Value.Value
|
||||
};
|
||||
|
||||
currentMessageId = chatResponse.MessageId;
|
||||
currentMessageId = textMessageId;
|
||||
}
|
||||
|
||||
// Emit text content if present
|
||||
@@ -577,9 +589,15 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
currentReasoningMessageId = null;
|
||||
}
|
||||
|
||||
// Each tool result is a distinct tool-role message on the AGUI wire.
|
||||
// MEAI's FunctionInvokingChatClient shares one synthetic MessageId
|
||||
// across all FunctionResultContent items, but the FE keys messages
|
||||
// by id, so emitting them with the same id collapses them in React
|
||||
// reconciliation. Derive a unique, deterministic per-result id from
|
||||
// the (LLM-assigned) call id.
|
||||
yield return new ToolCallResultEvent
|
||||
{
|
||||
MessageId = chatResponse.MessageId,
|
||||
MessageId = $"result-{functionResultContent.CallId}",
|
||||
ToolCallId = functionResultContent.CallId,
|
||||
Content = SerializeResultContent(functionResultContent, jsonSerializerOptions) ?? "",
|
||||
Role = AGUIRoles.Tool
|
||||
@@ -674,7 +692,7 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
// Text content event
|
||||
yield return new TextMessageContentEvent
|
||||
{
|
||||
MessageId = chatResponse.MessageId!,
|
||||
MessageId = textMessageId!,
|
||||
#if !NET
|
||||
Delta = Encoding.UTF8.GetString(dataContent.Data.ToArray())
|
||||
#else
|
||||
@@ -726,17 +744,4 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
_ => JsonSerializer.Serialize(functionResultContent.Result, options.GetTypeInfo(functionResultContent.Result.GetType())),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool ContainsToolResult(ChatResponseUpdate chatResponse)
|
||||
{
|
||||
foreach (AIContent content in chatResponse.Contents)
|
||||
{
|
||||
if (content is FunctionResultContent)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user