mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98861877bc | ||
|
|
6c8b821b2c | ||
|
|
0c02824853 | ||
|
|
836d22b205 | ||
|
|
19b6f3a5d9 | ||
|
|
3b80c9e50d |
@@ -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<IChatClient>("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",
|
||||
|
||||
+80
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Implements ChatMessageStore for managing messages within a conversation thread using IConversationStorage.
|
||||
/// </summary>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// constructs
|
||||
/// </summary>
|
||||
public ConversationAgentThreadStore(ChatMessageStoreFactoryContext ctx)
|
||||
: this(ctx, new InMemoryConversationStorage())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constructs
|
||||
/// </summary>
|
||||
internal ConversationAgentThreadStore(ChatMessageStoreFactoryContext ctx, IConversationStorage conversationStorage)
|
||||
{
|
||||
this._conversationStorage = conversationStorage;
|
||||
this._ctx = ctx;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// add
|
||||
/// </summary>
|
||||
public override Task AddMessagesAsync(IEnumerable<ChatMessage> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// get
|
||||
/// </summary>
|
||||
public override async Task<IEnumerable<ChatMessage>> GetMessagesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// missing conversationId
|
||||
|
||||
ListResponse<ItemResource> items = await this._conversationStorage.ListItemsAsync(conversationId: "2", cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
return items.Data.Select(x => x.ToChatMessage());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// serialize
|
||||
/// </summary>
|
||||
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
// no conversationId. What can be even done here?
|
||||
|
||||
return JsonDocument.Parse("{}").RootElement;
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ internal sealed partial class IdGenerator
|
||||
this._random = randomSeed.HasValue ? new Random(randomSeed.Value) : null;
|
||||
this.ResponseId = responseId ?? NewId("resp", random: this._random);
|
||||
this.ConversationId = conversationId ?? NewId("conv", random: this._random);
|
||||
this.IsNewConversation = conversationId is null;
|
||||
this._partitionId = GetPartitionIdOrDefault(this.ConversationId) ?? string.Empty;
|
||||
}
|
||||
|
||||
@@ -59,6 +60,11 @@ internal sealed partial class IdGenerator
|
||||
/// </summary>
|
||||
public string ConversationId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this is a new conversation.
|
||||
/// </summary>
|
||||
public bool IsNewConversation { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Generates a new ID.
|
||||
/// </summary>
|
||||
|
||||
@@ -26,6 +26,11 @@ internal sealed class AgentInvocationContext(IdGenerator idGenerator, JsonSerial
|
||||
/// </summary>
|
||||
public string ConversationId => this.IdGenerator.ConversationId;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true, if conversation is new.
|
||||
/// </summary>
|
||||
public bool IsNewConversation => this.IdGenerator.IsNewConversation;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the JSON serializer options.
|
||||
/// </summary>
|
||||
|
||||
+344
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for converting between ItemResource and ChatMessage.
|
||||
/// </summary>
|
||||
internal static class ItemResourceExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts an ItemResource to a ChatMessage.
|
||||
/// </summary>
|
||||
/// <param name="itemResource">The ItemResource to convert.</param>
|
||||
/// <returns>A ChatMessage.</returns>
|
||||
/// <exception cref="NotSupportedException">Thrown when the ItemResource type is not supported for conversion to ChatMessage.</exception>
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a ChatMessage to ItemResource objects.
|
||||
/// This method requires an IdGenerator to create unique IDs for the generated resources.
|
||||
/// </summary>
|
||||
/// <param name="message">The chat message to convert.</param>
|
||||
/// <param name="idGenerator">The ID generator to use for creating IDs.</param>
|
||||
/// <param name="jsonSerializerOptions">The JSON serializer options to use.</param>
|
||||
/// <returns>An enumerable of ItemResource objects.</returns>
|
||||
public static IEnumerable<ItemResource> ToItemResources(this ChatMessage message, IdGenerator idGenerator, JsonSerializerOptions jsonSerializerOptions)
|
||||
{
|
||||
return message.ToItemResource(idGenerator, jsonSerializerOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a ChatMessage to a single ItemResource (message type) when it contains only message content.
|
||||
/// For messages with function calls, use ToItemResources instead.
|
||||
/// </summary>
|
||||
/// <param name="message">The chat message to convert.</param>
|
||||
/// <param name="id">The ID to assign to the message resource.</param>
|
||||
/// <returns>A ResponsesMessageItemResource.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown when the message contains function calls or function results.</exception>
|
||||
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!
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts multiple ItemResources to ChatMessages.
|
||||
/// Adjacent message items with the same role will be combined into a single ChatMessage.
|
||||
/// </summary>
|
||||
/// <param name="itemResources">The ItemResources to convert.</param>
|
||||
/// <returns>An enumerable of ChatMessages.</returns>
|
||||
public static IEnumerable<ChatMessage> ToChatMessages(this IEnumerable<ItemResource> itemResources)
|
||||
{
|
||||
ChatMessage? currentMessage = null;
|
||||
var currentContents = new List<AIContent>();
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a FunctionToolCallItemResource to FunctionCallContent.
|
||||
/// Uses the official Microsoft.Extensions.AI pattern via CreateFromParsedArguments to properly handle parsing errors.
|
||||
/// </summary>
|
||||
/// <param name="functionCall">The function call item resource to convert.</param>
|
||||
/// <returns>A FunctionCallContent with properly parsed arguments. If parsing fails, the Exception property will be set.</returns>
|
||||
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<string, object?>)JsonSerializer.Deserialize(json, OpenAIHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary<string, object?>)))!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a FunctionToolCallOutputItemResource to FunctionResultContent.
|
||||
/// </summary>
|
||||
/// <param name="functionOutput">The function output item resource to convert.</param>
|
||||
/// <returns>A FunctionResultContent.</returns>
|
||||
public static FunctionResultContent ToFunctionResultContent(this FunctionToolCallOutputItemResource functionOutput)
|
||||
{
|
||||
return new FunctionResultContent(functionOutput.CallId, functionOutput.Output);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
/// <param name="message">The chat message to convert.</param>
|
||||
/// <returns>An enumerable of ItemParam objects.</returns>
|
||||
public static IEnumerable<ItemParam> 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<string, object?>)))
|
||||
};
|
||||
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<ItemContent> 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 }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a ChatMessage to a single ItemParam (message type) when it contains only message content.
|
||||
/// For messages with function calls, use ToItemParams instead.
|
||||
/// </summary>
|
||||
/// <param name="message">The chat message to convert.</param>
|
||||
/// <returns>A ResponsesMessageItemParam.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown when the message contains function calls or function results.</exception>
|
||||
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<ItemContent> 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 }
|
||||
};
|
||||
}
|
||||
}
|
||||
+15
-3
@@ -77,10 +77,13 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
string agentName = GetAgentName(request)!;
|
||||
AIAgent agent = this._serviceProvider.GetRequiredKeyedService<AIAgent>(agentName);
|
||||
string conversationId = context.ConversationId;
|
||||
|
||||
var agent = this._serviceProvider.GetRequiredKeyedService<AIAgent>(agentName);
|
||||
var threadStore = this._serviceProvider.GetKeyedService<AgentThreadStore>(agent.Name);
|
||||
|
||||
var chatOptions = new ChatOptions
|
||||
{
|
||||
ConversationId = request.Conversation?.Id,
|
||||
Temperature = (float?)request.Temperature,
|
||||
TopP = (float?)request.TopP,
|
||||
MaxOutputTokens = request.MaxOutputTokens,
|
||||
@@ -90,16 +93,25 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor
|
||||
var options = new ChatClientAgentRunOptions(chatOptions);
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
AgentThread thread = !context.IsNewConversation && threadStore is not null
|
||||
? await threadStore.GetThreadAsync(agent, conversationId, cancellationToken).ConfigureAwait(false)
|
||||
: agent.GetNewThread();
|
||||
|
||||
foreach (var inputMessage in request.Input.GetInputMessages())
|
||||
{
|
||||
messages.Add(inputMessage.ToChatMessage());
|
||||
}
|
||||
|
||||
await foreach (var streamingEvent in agent.RunStreamingAsync(messages, options: options, cancellationToken: cancellationToken)
|
||||
await foreach (var streamingEvent in agent.RunStreamingAsync(messages, thread, options: options, cancellationToken: cancellationToken)
|
||||
.ToStreamingResponseAsync(request, context, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return streamingEvent;
|
||||
}
|
||||
|
||||
if (threadStore is not null && thread is not null)
|
||||
{
|
||||
await threadStore.SaveThreadAsync(agent, conversationId, thread, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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<ResponseError?> 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<ItemResource>(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)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
# Problem: OpenAI Responses and conversation management
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1) We are talking about the case of **Hosting** an agent. **Hosting** here means that agent is registered in the DI of aspnetcore app, it is resolved per e.g. HTTP request to the server and invokes the agent under the hood. It can be exposed via any protocol we can consider implementing.
|
||||
|
||||
2) Imagine user is using conversations feature of the OpenAI Responses protocol, meaning they are communicating with the specific `conversation` id. That means that agent invocation has to obtain the context of specific conversation (like load messages / metadata / whatever).
|
||||
|
||||
OpenAI Responses protocol has the following requirements (for the straightforward implementation):
|
||||
- persist conversation and its messages
|
||||
- load conversation by its id
|
||||
- load its messages by id
|
||||
- save conversation and messages by id
|
||||
|
||||
_Note: we are not considering anything crazy here, like forking the conversation and etc. very basic stuff_
|
||||
|
||||
## Goal
|
||||
|
||||
[HostedAgentResponseExecutor](./HostedAgentResponseExecutor.cs) processes the input in the OpenAI Responses format (see `ExecuteAsync`). It has `AgentInvocationContext` context (the metadata about the call including responseId and conversationId) and `CreateResponse` request having the actual request data.
|
||||
|
||||
There are 2 abstractions to work out the conversation persistently today: `AgentThreadStore` and `ChatMessageStore` (taking into the account that we have `ChatClientAgent` - the most popular variation of the agent).
|
||||
|
||||
We want to use the existing API to achieve a way to support given requirement for implementing OpenAI Responses protocol.
|
||||
|
||||
## Implementation: What we have today
|
||||
|
||||
Here what we have today:
|
||||
```csharp
|
||||
string agentName = GetAgentName(request)!;
|
||||
string conversationId = context.ConversationId;
|
||||
|
||||
var agent = this._serviceProvider.GetRequiredKeyedService<AIAgent>(agentName);
|
||||
var chatOptions = new ChatOptions
|
||||
{
|
||||
Temperature = (float?)request.Temperature,
|
||||
TopP = (float?)request.TopP,
|
||||
MaxOutputTokens = request.MaxOutputTokens,
|
||||
Instructions = request.Instructions,
|
||||
ModelId = request.Model,
|
||||
};
|
||||
var options = new ChatClientAgentRunOptions(chatOptions);
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
foreach (var inputMessage in request.Input.GetInputMessages())
|
||||
{
|
||||
messages.Add(inputMessage.ToChatMessage());
|
||||
}
|
||||
|
||||
// agent invocation
|
||||
await foreach (var streamingEvent in agent.RunStreamingAsync(messages, thread, options: options, cancellationToken: cancellationToken)
|
||||
.ToStreamingResponseAsync(request, context, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return streamingEvent;
|
||||
}
|
||||
```
|
||||
|
||||
The code does the following:
|
||||
1) resolves the agent based on request data. For example we now will be using agent "pirate"
|
||||
2) Fills the messages collection, prepares options to run
|
||||
3) invokes the agent via `RunStreamingAsync`
|
||||
|
||||
Problems:
|
||||
1) We are not having any code which restores the `AgentThread` here
|
||||
2) We do not save messages anywhere either
|
||||
|
||||
This code is straightforward, but does not achieve what we want
|
||||
|
||||
## Implementation 1: Use `AgentThreadStore`
|
||||
|
||||
Lets only use `AgentThreadStore` for a second. Now we will be resolving the `AgentThreadStore` (consider it is attached to the agent, or uses default store).
|
||||
|
||||
Firstly, registration is quite easy. `.WithInMemoryThreadStore()` here basically registers the implementaiton of `AgentThreadStore` in the DI, which can be resolved later.
|
||||
```csharp
|
||||
var pirateAgentBuilder = builder.AddAIAgent(
|
||||
"pirate", instructions: "You are a pirate. Speak like a pirate", chatClientServiceKey: "chat-model")
|
||||
.WithInMemoryThreadStore();
|
||||
```
|
||||
|
||||
The code part is still pretty easy:
|
||||
```csharp
|
||||
var agent = this._serviceProvider.GetRequiredKeyedService<AIAgent>(agentName);
|
||||
var threadStore = this._serviceProvider.GetKeyedService<AgentThreadStore>(agent.Name);
|
||||
|
||||
AgentThread thread = !context.IsNewConversation && threadStore is not null
|
||||
? await threadStore.GetThreadAsync(agent, conversationId, cancellationToken).ConfigureAwait(false)
|
||||
: agent.GetNewThread();
|
||||
|
||||
// agent invocation
|
||||
await foreach (var streamingEvent in agent.RunStreamingAsync(messages, thread, options: options, cancellationToken: cancellationToken)
|
||||
.ToStreamingResponseAsync(request, context, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return streamingEvent;
|
||||
}
|
||||
|
||||
if (threadStore is not null && thread is not null)
|
||||
{
|
||||
await threadStore.SaveThreadAsync(agent, conversationId, thread, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
```
|
||||
|
||||
Now the difference is that we construct a thread (via known `agent.GetNewThread()` or `agent.DeserializeThread(JsonElement)`) and pass it in the agent. Then we save the thread using `thread.Serialize()`.
|
||||
|
||||
Benefits:
|
||||
1) we solved the problem that we pass the "conversation" messages to the invocation - now agent has some context
|
||||
|
||||
Problems:
|
||||
1) Whatever we restore and save is in an unknown format - basically `JsonElement`. We cannot restore conversationId , responseId, any metadata or any messages from the `thread` variable that was updated during `agent.RunStreamingAsync()`
|
||||
2) Additionally to having some kind of store used in this sample (`AgentThreadStore`) we still need something else. Ideally it should be a single store solving the problems of restoring/saving conversation AND having an ability to lookup into it based on the conversation id.
|
||||
|
||||
## Implementation 2: Use `ChatMessageStore`
|
||||
|
||||
Building `AIAgent` and registering is a bit different but follows same principle: pass in the `ChatMessageStoreFactory`:
|
||||
```csharp
|
||||
var testAgent = builder.AddAIAgent("problemCheck", (sp, name) =>
|
||||
{
|
||||
var chatClient = sp.GetRequiredKeyedService<IChatClient>("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!"
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
And we dont need any changes to the initial implementation: this store is already inside of the agent, so it will be used by the internals. The interesting part is how `ConversationAgentThreadStore` is implemented:
|
||||
```csharp
|
||||
public class ConversationAgentThreadStore : ChatMessageStore
|
||||
{
|
||||
private readonly IConversationStorage _conversationStorage;
|
||||
private readonly ChatMessageStoreFactoryContext _ctx;
|
||||
|
||||
public ConversationAgentThreadStore(ChatMessageStoreFactoryContext ctx)
|
||||
: this(ctx, new InMemoryConversationStorage())
|
||||
{
|
||||
}
|
||||
|
||||
internal ConversationAgentThreadStore(ChatMessageStoreFactoryContext ctx, IConversationStorage conversationStorage)
|
||||
{
|
||||
this._conversationStorage = conversationStorage;
|
||||
this._ctx = ctx;
|
||||
}
|
||||
|
||||
public override Task AddMessagesAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// missing responseId, conversationId
|
||||
|
||||
var idGenerator = new IdGenerator(responseId: ..., conversationId: ...);
|
||||
var items = messages.SelectMany(x => x.ToItemResource(idGenerator, OpenAIHostingJsonUtilities.DefaultOptions));
|
||||
return this._conversationStorage.AddItemsAsync(conversationId: ..., items, cancellationToken);
|
||||
}
|
||||
|
||||
public override async Task<IEnumerable<ChatMessage>> GetMessagesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// missing conversationId
|
||||
|
||||
ListResponse<ItemResource> items = await this._conversationStorage.ListItemsAsync(conversationId: ..., cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
return items.Data.Select(x => x.ToChatMessage());
|
||||
}
|
||||
|
||||
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
// no conversationId. What can be even done here?
|
||||
return JsonDocument.Parse("{}").RootElement;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The API is basically - `Get` or `Add` messages and `Serialize`. Since we have a singleton agent per app, and it could be used per multiple users at the same time, and it has a single messageStore here for every one of request handlers.
|
||||
|
||||
However, `AddMessagesAsync(IEnumerable<ChatMessage>, CancellationToken)` or `GetMessagesAsync(CancellationToken)` both do not have any context passed here - we do not know the `responseId` or `conversationId` which is required by the protocol and by a matching protocol features interface `IConversationStorage` (an explicit implementation, not a public API).
|
||||
|
||||
Moreover, `Serialize` only allows to store the collection of `ChatMessage` - otherwise no metadata can be pushed into the callsite of the `ChatMessageStore` implementation (in this case `ConversationAgentThreadStore`).
|
||||
|
||||
Benefits:
|
||||
1) does not build a separate layer with thread usage opposed to #2
|
||||
2) has a visibility into `ChatMessage` which is quite nice abstraction to give to the user to handle
|
||||
|
||||
Problems:
|
||||
1) Does not have **any context** in the API, which dissallowes lookup into `ChatOptions`, `AgentThread` or the `Agent` itself. In this case misses the necessary `conversationId` and `responseId`.
|
||||
|
||||
## Conclusions
|
||||
|
||||
Both `AgentThreadStore` and `ChatMessageStore` are unfitting API for the very basic OpenAI Responses implementation, and the very least thing to do is to use both + make changes to `ChatMessageStore` to have some context of the invocation passed inside it.
|
||||
|
||||
That means API has to be redesigned to make it more extensible and convenient. The important bit is that today there are several abstractions:
|
||||
1) AIAgent
|
||||
2) AgentThread
|
||||
3) AgentRunOptions
|
||||
4) ChatMessageStore
|
||||
|
||||
which are all interconnected in such a way, that **only a single combination of all of them** can work together. That is an antonym of "abstraction" - a specific implementation fitting the API that can be passed into basically any other component requiring abstract type.
|
||||
Reference in New Issue
Block a user