// 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 AsChatMessages( this IEnumerable 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? 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(); 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(); pendingId ??= message.Id; if (!string.IsNullOrEmpty(assistantMessage.Content)) { pendingContents.Add(new TextContent(assistantMessage.Content)); } foreach (var toolCall in assistantMessage.ToolCalls) { Dictionary? arguments = null; if (!string.IsNullOrEmpty(toolCall.Function.Arguments)) { arguments = (Dictionary?)JsonSerializer.Deserialize( toolCall.Function.Arguments, jsonSerializerOptions.GetTypeInfo(typeof(Dictionary))); } 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 AsAGUIMessages( this IEnumerable 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().FirstOrDefault(); if (reasoning is null) { return null; } var text = string.Join( string.Empty, message.Contents.OfType() .Where(r => !string.IsNullOrEmpty(r.Text)) .Select(r => r.Text)); var protectedData = message.Contents.OfType() .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? 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))); 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 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}"); }