From 6c8b821b2cfa9da5ecf1a7438aa8a9d9dab3e4ea Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Tue, 18 Nov 2025 00:32:13 +0100 Subject: [PATCH] problem: cannot operate with message store without any context --- .../AgentWebChat.AgentHost/Program.cs | 12 + .../ConversationAgentThreadStore.cs | 80 ++++ .../Converters/ItemResourceExtensions.cs | 344 ++++++++++++++++++ .../Responses/InMemoryResponsesService.cs | 17 - 4 files changed, 436 insertions(+), 17 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/ConversationAgentThreadStore.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemResourceExtensions.cs diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs index 46af2a5b19..094a3c0af1 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs @@ -6,6 +6,7 @@ using AgentWebChat.AgentHost.Custom; using AgentWebChat.AgentHost.Utilities; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting; +using Microsoft.Agents.AI.Hosting.OpenAI.Conversations; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; @@ -21,6 +22,17 @@ builder.Services.AddProblemDetails(); // Configure the chat model and our agent. builder.AddKeyedChatClient("chat-model"); +var testAgent = builder.AddAIAgent("problemCheck", (sp, name) => +{ + var chatClient = sp.GetRequiredKeyedService("chat-model"); + return new ChatClientAgent(chatClient, options: new() + { + ChatMessageStoreFactory = ctx => new ConversationAgentThreadStore(ctx), + Name = "problemCheck", + Instructions = "You need to state a problem and fix it!" + }); +}); + var pirateAgentBuilder = builder.AddAIAgent( "pirate", instructions: "You are a pirate. Speak like a pirate", diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/ConversationAgentThreadStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/ConversationAgentThreadStore.cs new file mode 100644 index 0000000000..23c42ac9d9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/ConversationAgentThreadStore.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.OpenAI.Models; +using Microsoft.Agents.AI.Hosting.OpenAI.Responses; +using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Converters; +using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models; +using Microsoft.Extensions.AI; +using static Microsoft.Agents.AI.ChatClientAgentOptions; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.Conversations; + +/// +/// Implements ChatMessageStore for managing messages within a conversation thread using IConversationStorage. +/// +public class ConversationAgentThreadStore : ChatMessageStore +{ + private readonly IConversationStorage _conversationStorage; + +#pragma warning disable IDE0052 // Remove unread private members + private readonly ChatMessageStoreFactoryContext _ctx; +#pragma warning restore IDE0052 // Remove unread private members + + /// + /// constructs + /// + public ConversationAgentThreadStore(ChatMessageStoreFactoryContext ctx) + : this(ctx, new InMemoryConversationStorage()) + { + } + + /// + /// Constructs + /// + internal ConversationAgentThreadStore(ChatMessageStoreFactoryContext ctx, IConversationStorage conversationStorage) + { + this._conversationStorage = conversationStorage; + this._ctx = ctx; + } + + /// + /// add + /// + public override Task AddMessagesAsync(IEnumerable messages, CancellationToken cancellationToken = default) + { + // missing responseId, conversationId + + //var idGenerator = new IdGenerator(responseId: ..., conversationId: ...); + var idGenerator = new IdGenerator(responseId: "1", conversationId: "2"); + + var items = messages.SelectMany(x => x.ToItemResource(idGenerator, OpenAIHostingJsonUtilities.DefaultOptions)); + + return this._conversationStorage.AddItemsAsync(conversationId: "2", items, cancellationToken); + } + + /// + /// get + /// + public override async Task> GetMessagesAsync(CancellationToken cancellationToken = default) + { + // missing conversationId + + ListResponse items = await this._conversationStorage.ListItemsAsync(conversationId: "2", cancellationToken: cancellationToken).ConfigureAwait(false); + return items.Data.Select(x => x.ToChatMessage()); + } + + /// + /// serialize + /// + public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null) + { + // no conversationId. What can be even done here? + + return JsonDocument.Parse("{}").RootElement; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemResourceExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemResourceExtensions.cs new file mode 100644 index 0000000000..c9e92dda60 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemResourceExtensions.cs @@ -0,0 +1,344 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Converters; + +/// +/// Extension methods for converting between ItemResource and ChatMessage. +/// +internal static class ItemResourceExtensions +{ + /// + /// Converts an ItemResource to a ChatMessage. + /// + /// The ItemResource to convert. + /// A ChatMessage. + /// Thrown when the ItemResource type is not supported for conversion to ChatMessage. + public static ChatMessage ToChatMessage(this ItemResource itemResource) + { + if (itemResource is ResponsesMessageItemResource messageItem) + { + var content = messageItem switch + { + ResponsesAssistantMessageItemResource assistant => assistant.Content, + ResponsesUserMessageItemResource user => user.Content, + ResponsesSystemMessageItemResource system => system.Content, + ResponsesDeveloperMessageItemResource developer => developer.Content, + _ => throw new NotSupportedException($"Message item type {messageItem.GetType().Name} not supported") + }; + + // Convert ItemContent to AIContent using the existing converter + var aiContents = content + .Select(ItemContentConverter.ToAIContent) + .Where(c => c is not null) + .ToList(); + return new ChatMessage(messageItem.Role, aiContents!); + } + + if (itemResource is FunctionToolCallItemResource functionCall) + { + return new ChatMessage(ChatRole.Assistant, [ + functionCall.ToFunctionCallContent() + ]); + } + + if (itemResource is FunctionToolCallOutputItemResource functionOutput) + { + return new ChatMessage(ChatRole.Tool, [ + functionOutput.ToFunctionResultContent() + ]); + } + + throw new NotSupportedException($"ItemResource type {itemResource.GetType().Name} not supported for conversion to ChatMessage"); + } + + /// + /// Converts a ChatMessage to ItemResource objects. + /// This method requires an IdGenerator to create unique IDs for the generated resources. + /// + /// The chat message to convert. + /// The ID generator to use for creating IDs. + /// The JSON serializer options to use. + /// An enumerable of ItemResource objects. + public static IEnumerable ToItemResources(this ChatMessage message, IdGenerator idGenerator, JsonSerializerOptions jsonSerializerOptions) + { + return message.ToItemResource(idGenerator, jsonSerializerOptions); + } + + /// + /// Converts a ChatMessage to a single ItemResource (message type) when it contains only message content. + /// For messages with function calls, use ToItemResources instead. + /// + /// The chat message to convert. + /// The ID to assign to the message resource. + /// A ResponsesMessageItemResource. + /// Thrown when the message contains function calls or function results. + public static ResponsesMessageItemResource ToMessageItemResource(this ChatMessage message, string id) + { + // Check if the message contains function calls or function results + if (message.Contents.Any(c => c is FunctionCallContent or FunctionResultContent)) + { + throw new InvalidOperationException("Cannot convert a ChatMessage with function calls or function results to a single MessageItemResource. Use ToItemResources instead."); + } + + // Convert all contents to ItemContent + var contents = message.Contents + .Select(ItemContentConverter.ToItemContent) + .Where(c => c is not null) + .ToList(); + + // Create the appropriate message item resource based on role + return message.Role.Value switch + { + "assistant" => new ResponsesAssistantMessageItemResource + { + Id = id, + Status = ResponsesMessageItemResourceStatus.Completed, + Content = contents! + }, + "user" => new ResponsesUserMessageItemResource + { + Id = id, + Status = ResponsesMessageItemResourceStatus.Completed, + Content = contents! + }, + "system" => new ResponsesSystemMessageItemResource + { + Id = id, + Status = ResponsesMessageItemResourceStatus.Completed, + Content = contents! + }, + "developer" => new ResponsesDeveloperMessageItemResource + { + Id = id, + Status = ResponsesMessageItemResourceStatus.Completed, + Content = contents! + }, + _ => new ResponsesAssistantMessageItemResource + { + Id = id, + Status = ResponsesMessageItemResourceStatus.Completed, + Content = contents! + } + }; + } + + /// + /// Converts multiple ItemResources to ChatMessages. + /// Adjacent message items with the same role will be combined into a single ChatMessage. + /// + /// The ItemResources to convert. + /// An enumerable of ChatMessages. + public static IEnumerable ToChatMessages(this IEnumerable itemResources) + { + ChatMessage? currentMessage = null; + var currentContents = new List(); + + foreach (var item in itemResources) + { + if (item is ResponsesMessageItemResource messageItem) + { + // Check if we should start a new message or continue the current one + if (currentMessage is not null && currentMessage.Role != messageItem.Role) + { + // Yield the current message and start a new one + yield return new ChatMessage(currentMessage.Role, [.. currentContents]); + currentContents = []; + } + + // Add contents from this message item + var aiContents = messageItem switch + { + ResponsesAssistantMessageItemResource assistant => assistant.Content, + ResponsesUserMessageItemResource user => user.Content, + ResponsesSystemMessageItemResource system => system.Content, + ResponsesDeveloperMessageItemResource developer => developer.Content, + _ => [] + }; + + foreach (var content in aiContents) + { + if (ItemContentConverter.ToAIContent(content) is { } aiContent) + { + currentContents.Add(aiContent); + } + } + + // Initialize or update current message + currentMessage = new ChatMessage(messageItem.Role, [.. currentContents]); + } + else if (item is FunctionToolCallItemResource functionCall) + { + // Function calls are always from assistant + if (currentMessage is not null && currentMessage.Role != ChatRole.Assistant) + { + yield return new ChatMessage(currentMessage.Role, [.. currentContents]); + currentContents = []; + currentMessage = null; + } + + currentContents.Add(functionCall.ToFunctionCallContent()); + currentMessage = new ChatMessage(ChatRole.Assistant, [.. currentContents]); + } + else if (item is FunctionToolCallOutputItemResource functionOutput) + { + // Function outputs are always from tool role + if (currentMessage is not null && currentMessage.Role != ChatRole.Tool) + { + yield return new ChatMessage(currentMessage.Role, [.. currentContents]); + currentContents = []; + currentMessage = null; + } + + currentContents.Add(functionOutput.ToFunctionResultContent()); + currentMessage = new ChatMessage(ChatRole.Tool, [.. currentContents]); + } + } + + // Yield the last message if any + if (currentMessage is not null && currentContents.Count > 0) + { + yield return new ChatMessage(currentMessage.Role, [.. currentContents]); + } + } + + /// + /// Converts a FunctionToolCallItemResource to FunctionCallContent. + /// Uses the official Microsoft.Extensions.AI pattern via CreateFromParsedArguments to properly handle parsing errors. + /// + /// The function call item resource to convert. + /// A FunctionCallContent with properly parsed arguments. If parsing fails, the Exception property will be set. + public static FunctionCallContent ToFunctionCallContent(this FunctionToolCallItemResource functionCall) + { + // Use the same pattern as Microsoft.Extensions.AI.OpenAI's ParseCallContent method + // This properly handles parsing errors by setting the Exception property on FunctionCallContent + return FunctionCallContent.CreateFromParsedArguments( + functionCall.Arguments ?? "{}", + functionCall.CallId, + functionCall.Name, + static json => (IDictionary)JsonSerializer.Deserialize(json, OpenAIHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary)))!); + } + + /// + /// Converts a FunctionToolCallOutputItemResource to FunctionResultContent. + /// + /// The function output item resource to convert. + /// A FunctionResultContent. + public static FunctionResultContent ToFunctionResultContent(this FunctionToolCallOutputItemResource functionOutput) + { + return new FunctionResultContent(functionOutput.CallId, functionOutput.Output); + } + + /// + /// Converts a ChatMessage to ItemParam objects (input models without IDs). + /// This is useful for creating items in the Conversations API. + /// Filters out events that don't map well to ItemParams (e.g., messages with no convertible content). + /// + /// The chat message to convert. + /// An enumerable of ItemParam objects. + public static IEnumerable ToItemParams(this ChatMessage message) + { + // Separate function call/result contents from regular message contents + foreach (AIContent content in message.Contents) + { + switch (content) + { + case FunctionCallContent functionCallContent: + yield return new FunctionToolCallItemParam + { + CallId = functionCallContent.CallId, + Name = functionCallContent.Name, + Arguments = JsonSerializer.Serialize( + functionCallContent.Arguments, + OpenAIHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary))) + }; + break; + + case FunctionResultContent functionResultContent: + string output = functionResultContent.Exception is not null + ? $"{functionResultContent.Exception.GetType().Name}(\"{functionResultContent.Exception.Message}\")" + : $"{functionResultContent.Result?.ToString() ?? "(null)"}"; + yield return new FunctionToolCallOutputItemParam + { + CallId = functionResultContent.CallId, + Output = output + }; + break; + } + } + + // Convert regular message contents + List regularContents = []; + foreach (AIContent content in message.Contents) + { + if (content is not FunctionCallContent and not FunctionResultContent && + ItemContentConverter.ToItemContent(content) is { } itemContent) + { + regularContents.Add(itemContent); + } + } + + // Only create a message item if we have convertible contents + // This filters out messages that contain only non-convertible content (e.g., UsageContent) + if (regularContents.Count > 0) + { + InputMessageContent messageContent = InputMessageContent.FromContents(regularContents); + + yield return message.Role.Value.ToUpperInvariant() switch + { + "USER" => new ResponsesUserMessageItemParam { Content = messageContent }, + "ASSISTANT" => new ResponsesAssistantMessageItemParam { Content = messageContent }, + "SYSTEM" => new ResponsesSystemMessageItemParam { Content = messageContent }, + "DEVELOPER" => new ResponsesDeveloperMessageItemParam { Content = messageContent }, + _ => new ResponsesUserMessageItemParam { Content = messageContent } + }; + } + } + + /// + /// Converts a ChatMessage to a single ItemParam (message type) when it contains only message content. + /// For messages with function calls, use ToItemParams instead. + /// + /// The chat message to convert. + /// A ResponsesMessageItemParam. + /// Thrown when the message contains function calls or function results. + public static ResponsesMessageItemParam ToMessageItemParam(this ChatMessage message) + { + // Check if the message contains function calls or function results + if (message.Contents.Any(c => c is FunctionCallContent or FunctionResultContent)) + { + throw new InvalidOperationException("Cannot convert a ChatMessage with function calls or function results to a single MessageItemParam. Use ToItemParams instead."); + } + + // Convert all contents to ItemContent + List contents = []; + foreach (AIContent content in message.Contents) + { + if (ItemContentConverter.ToItemContent(content) is { } itemContent) + { + contents.Add(itemContent); + } + } + + // Create InputMessageContent + InputMessageContent messageContent = contents.Count > 0 + ? InputMessageContent.FromContents(contents) + : InputMessageContent.FromText(message.Text ?? string.Empty); + + // Create the appropriate message item param based on role + return message.Role.Value.ToUpperInvariant() switch + { + "USER" => new ResponsesUserMessageItemParam { Content = messageContent }, + "ASSISTANT" => new ResponsesAssistantMessageItemParam { Content = messageContent }, + "SYSTEM" => new ResponsesSystemMessageItemParam { Content = messageContent }, + "DEVELOPER" => new ResponsesDeveloperMessageItemParam { Content = messageContent }, + _ => new ResponsesUserMessageItemParam { Content = messageContent } + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs index 2f5b3f4660..ad1ccc1b36 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs @@ -22,7 +22,6 @@ internal sealed class InMemoryResponsesService : IResponsesService, IDisposable private readonly IResponseExecutor _executor; private readonly MemoryCache _cache; private readonly InMemoryStorageOptions _options; - private readonly Conversations.IConversationStorage? _conversationStorage; private sealed class ResponseState { @@ -144,7 +143,6 @@ internal sealed class InMemoryResponsesService : IResponsesService, IDisposable this._executor = executor; this._options = options; this._cache = new MemoryCache(options.ToMemoryCacheOptions()); - this._conversationStorage = conversationStorage; } public async ValueTask ValidateRequestAsync( @@ -440,21 +438,6 @@ internal sealed class InMemoryResponsesService : IResponsesService, IDisposable } } - // Add both input and output items to conversation storage if available - // This happens AFTER successful execution, in line with OpenAI's behavior - if (this._conversationStorage is not null && request.Conversation?.Id is not null) - { - var inputItems = GetInputItems(responseId, state); - var allItems = new List(inputItems.Count + outputItems.Count); - allItems.AddRange(inputItems); - allItems.AddRange(outputItems); - - if (allItems.Count > 0) - { - await this._conversationStorage.AddItemsAsync(request.Conversation.Id, allItems, linkedCts.Token).ConfigureAwait(false); - } - } - // Update response status to completed if not already in a terminal state if (!state.IsTerminal) {