mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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>
299 lines
12 KiB
C#
299 lines
12 KiB
C#
// Copyright (c) Microsoft. All rights reserved.
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Text.Json;
|
|
using Microsoft.Extensions.AI;
|
|
|
|
#if ASPNETCORE
|
|
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
|
|
#else
|
|
namespace Microsoft.Agents.AI.AGUI.Shared;
|
|
#endif
|
|
|
|
internal static class AGUIChatMessageExtensions
|
|
{
|
|
private static readonly ChatRole s_developerChatRole = new("developer");
|
|
|
|
public static IEnumerable<ChatMessage> AsChatMessages(
|
|
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)
|
|
{
|
|
case AGUIToolMessage toolMessage:
|
|
{
|
|
object? result;
|
|
if (string.IsNullOrEmpty(toolMessage.Content))
|
|
{
|
|
result = toolMessage.Content;
|
|
}
|
|
else
|
|
{
|
|
// Try to deserialize as JSON, but fall back to string if it fails
|
|
try
|
|
{
|
|
result = JsonSerializer.Deserialize(toolMessage.Content, AGUIJsonSerializerContext.Default.JsonElement);
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
result = toolMessage.Content;
|
|
}
|
|
}
|
|
|
|
yield return new ChatMessage(
|
|
role,
|
|
[
|
|
new FunctionResultContent(
|
|
toolMessage.ToolCallId,
|
|
result)
|
|
]);
|
|
break;
|
|
}
|
|
|
|
case AGUIReasoningMessage reasoningMessage:
|
|
{
|
|
var contents = new List<AIContent>();
|
|
|
|
if (!string.IsNullOrEmpty(reasoningMessage.Content))
|
|
{
|
|
contents.Add(new TextReasoningContent(reasoningMessage.Content)
|
|
{
|
|
ProtectedData = reasoningMessage.EncryptedValue
|
|
});
|
|
}
|
|
else if (!string.IsNullOrEmpty(reasoningMessage.EncryptedValue))
|
|
{
|
|
contents.Add(new TextReasoningContent("")
|
|
{
|
|
ProtectedData = reasoningMessage.EncryptedValue
|
|
});
|
|
}
|
|
|
|
yield return new ChatMessage(role, contents)
|
|
{
|
|
MessageId = message.Id
|
|
};
|
|
break;
|
|
}
|
|
|
|
case AGUIAssistantMessage assistantMessage when assistantMessage.ToolCalls is { Length: > 0 }:
|
|
{
|
|
pendingContents ??= new List<AIContent>();
|
|
pendingId ??= message.Id;
|
|
|
|
if (!string.IsNullOrEmpty(assistantMessage.Content))
|
|
{
|
|
pendingContents.Add(new TextContent(assistantMessage.Content));
|
|
}
|
|
|
|
foreach (var toolCall in assistantMessage.ToolCalls)
|
|
{
|
|
Dictionary<string, object?>? arguments = null;
|
|
if (!string.IsNullOrEmpty(toolCall.Function.Arguments))
|
|
{
|
|
arguments = (Dictionary<string, object?>?)JsonSerializer.Deserialize(
|
|
toolCall.Function.Arguments,
|
|
jsonSerializerOptions.GetTypeInfo(typeof(Dictionary<string, object?>)));
|
|
}
|
|
|
|
pendingContents.Add(new FunctionCallContent(
|
|
toolCall.Id,
|
|
toolCall.Function.Name,
|
|
arguments));
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
default:
|
|
{
|
|
string content = message switch
|
|
{
|
|
AGUIDeveloperMessage dev => dev.Content,
|
|
AGUISystemMessage sys => sys.Content,
|
|
AGUIUserMessage user => user.Content,
|
|
AGUIAssistantMessage asst => asst.Content,
|
|
_ => string.Empty
|
|
};
|
|
|
|
yield return new ChatMessage(role, content)
|
|
{
|
|
MessageId = message.Id
|
|
};
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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(
|
|
this IEnumerable<ChatMessage> chatMessages,
|
|
JsonSerializerOptions jsonSerializerOptions)
|
|
{
|
|
foreach (var message in chatMessages)
|
|
{
|
|
message.MessageId ??= Guid.NewGuid().ToString("N");
|
|
if (message.Role == ChatRole.Tool)
|
|
{
|
|
foreach (var toolMessage in MapToolMessages(jsonSerializerOptions, message))
|
|
{
|
|
yield return toolMessage;
|
|
}
|
|
}
|
|
else if (message.Role == ChatRole.Assistant)
|
|
{
|
|
var reasoningMessage = MapReasoningMessage(message);
|
|
if (reasoningMessage != null)
|
|
{
|
|
yield return reasoningMessage;
|
|
}
|
|
|
|
var assistantMessage = MapAssistantMessage(jsonSerializerOptions, message);
|
|
if (assistantMessage != null)
|
|
{
|
|
yield return assistantMessage;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
yield return message.Role.Value switch
|
|
{
|
|
AGUIRoles.Developer => new AGUIDeveloperMessage { Id = message.MessageId, Content = message.Text ?? string.Empty },
|
|
AGUIRoles.System => new AGUISystemMessage { Id = message.MessageId, Content = message.Text ?? string.Empty },
|
|
AGUIRoles.User => new AGUIUserMessage { Id = message.MessageId, Content = message.Text ?? string.Empty },
|
|
_ => throw new InvalidOperationException($"Unknown role: {message.Role.Value}")
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
private static AGUIReasoningMessage? MapReasoningMessage(ChatMessage message)
|
|
{
|
|
var reasoning = message.Contents.OfType<TextReasoningContent>().FirstOrDefault();
|
|
if (reasoning is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var text = string.Join(
|
|
string.Empty,
|
|
message.Contents.OfType<TextReasoningContent>()
|
|
.Where(r => !string.IsNullOrEmpty(r.Text))
|
|
.Select(r => r.Text));
|
|
|
|
var protectedData = message.Contents.OfType<TextReasoningContent>()
|
|
.Select(r => r.ProtectedData)
|
|
.LastOrDefault(p => !string.IsNullOrEmpty(p));
|
|
|
|
return new AGUIReasoningMessage
|
|
{
|
|
Id = message.MessageId,
|
|
Content = text,
|
|
EncryptedValue = protectedData,
|
|
};
|
|
}
|
|
|
|
private static AGUIAssistantMessage? MapAssistantMessage(JsonSerializerOptions jsonSerializerOptions, ChatMessage message)
|
|
{
|
|
List<AGUIToolCall>? toolCalls = null;
|
|
string? textContent = null;
|
|
|
|
foreach (var content in message.Contents)
|
|
{
|
|
if (content is FunctionCallContent functionCall)
|
|
{
|
|
var argumentsJson = functionCall.Arguments is null ?
|
|
"{}" :
|
|
JsonSerializer.Serialize(functionCall.Arguments, jsonSerializerOptions.GetTypeInfo(typeof(IDictionary<string, object?>)));
|
|
toolCalls ??= [];
|
|
toolCalls.Add(new AGUIToolCall
|
|
{
|
|
Id = functionCall.CallId,
|
|
Type = "function",
|
|
Function = new AGUIFunctionCall
|
|
{
|
|
Name = functionCall.Name,
|
|
Arguments = argumentsJson
|
|
}
|
|
});
|
|
}
|
|
else if (content is TextContent textContentItem)
|
|
{
|
|
textContent = textContentItem.Text;
|
|
}
|
|
}
|
|
|
|
// Create message with tool calls and/or text content
|
|
if (toolCalls?.Count > 0 || !string.IsNullOrEmpty(textContent))
|
|
{
|
|
return new AGUIAssistantMessage
|
|
{
|
|
Id = message.MessageId,
|
|
Content = textContent ?? string.Empty,
|
|
ToolCalls = toolCalls?.Count > 0 ? toolCalls.ToArray() : null
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static IEnumerable<AGUIToolMessage> MapToolMessages(JsonSerializerOptions jsonSerializerOptions, ChatMessage message)
|
|
{
|
|
foreach (var content in message.Contents)
|
|
{
|
|
if (content is FunctionResultContent functionResult)
|
|
{
|
|
yield return new AGUIToolMessage
|
|
{
|
|
Id = functionResult.CallId,
|
|
ToolCallId = functionResult.CallId,
|
|
Content = functionResult.Result is null ?
|
|
string.Empty :
|
|
JsonSerializer.Serialize(functionResult.Result, jsonSerializerOptions.GetTypeInfo(functionResult.Result.GetType()))
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
public static ChatRole MapChatRole(string role) =>
|
|
string.Equals(role, AGUIRoles.System, StringComparison.OrdinalIgnoreCase) ? ChatRole.System :
|
|
string.Equals(role, AGUIRoles.User, StringComparison.OrdinalIgnoreCase) ? ChatRole.User :
|
|
string.Equals(role, AGUIRoles.Assistant, StringComparison.OrdinalIgnoreCase) ? ChatRole.Assistant :
|
|
string.Equals(role, AGUIRoles.Developer, StringComparison.OrdinalIgnoreCase) ? s_developerChatRole :
|
|
string.Equals(role, AGUIRoles.Tool, StringComparison.OrdinalIgnoreCase) ? ChatRole.Tool :
|
|
string.Equals(role, AGUIRoles.Reasoning, StringComparison.OrdinalIgnoreCase) ? ChatRole.Assistant :
|
|
throw new InvalidOperationException($"Unknown chat role: {role}");
|
|
}
|