Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public AIAgentResponseExecutor(AIAgent agent)
public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
AgentInvocationContext context,
CreateResponse request,
IReadOnlyList<ChatMessage>? conversationHistory = null,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make sense to add conversationHistory to AgentInvocationContext?
History sounds like an essential part of the conversation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree @westey-m 's suggestion to store/load AgentSession per conversation with a custom ChatHistoryProvider would address this as well, making the separate parameter unnecessary.
Will work with westey-m on this broader change, likely in a separate PR.

[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// Create options with properties from the request
Expand All @@ -51,9 +52,14 @@ public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
};
var options = new ChatClientAgentRunOptions(chatOptions);

// Convert input to chat messages
// Convert input to chat messages, prepending conversation history if available
var messages = new List<ChatMessage>();

if (conversationHistory is not null)
{
messages.AddRange(conversationHistory);
}

foreach (var inputMessage in request.Input.GetInputMessages())
{
messages.Add(inputMessage.ToChatMessage());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Collections.Generic;
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>
/// Converts stored <see cref="ItemResource"/> objects back to <see cref="ChatMessage"/> objects
/// for injecting conversation history into agent execution.
/// </summary>
internal static class ItemResourceConversions
{
/// <summary>
/// Converts a sequence of <see cref="ItemResource"/> items to a list of <see cref="ChatMessage"/> objects.
/// Only converts message, function call, and function result items. Other item types are skipped.
/// </summary>
public static List<ChatMessage> ToChatMessages(IEnumerable<ItemResource> items)
{
var messages = new List<ChatMessage>();

foreach (var item in items)
{
switch (item)
{
case ResponsesUserMessageItemResource userMsg:
messages.Add(new ChatMessage(ChatRole.User, ConvertContents(userMsg.Content)));
break;

case ResponsesAssistantMessageItemResource assistantMsg:
messages.Add(new ChatMessage(ChatRole.Assistant, ConvertContents(assistantMsg.Content)));
break;

case ResponsesSystemMessageItemResource systemMsg:
messages.Add(new ChatMessage(ChatRole.System, ConvertContents(systemMsg.Content)));
break;

case ResponsesDeveloperMessageItemResource developerMsg:
messages.Add(new ChatMessage(new ChatRole("developer"), ConvertContents(developerMsg.Content)));
break;

case FunctionToolCallItemResource funcCall:
var arguments = ParseArguments(funcCall.Arguments);
messages.Add(new ChatMessage(ChatRole.Assistant,
[
new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)
]));
break;

case FunctionToolCallOutputItemResource funcOutput:
messages.Add(new ChatMessage(ChatRole.Tool,
[
new FunctionResultContent(funcOutput.CallId, funcOutput.Output)
]));
break;

// Skip all other item types (reasoning, executor_action, web_search, etc.)
// They are not relevant for conversation context.
}
}

return messages;
}

private static List<AIContent> ConvertContents(List<ItemContent> contents)
{
var result = new List<AIContent>();
foreach (var content in contents)
{
var aiContent = ItemContentConverter.ToAIContent(content);
if (aiContent is not null)
{
result.Add(aiContent);
}
}

return result;
}

private static Dictionary<string, object?>? ParseArguments(string? argumentsJson)
{
if (string.IsNullOrEmpty(argumentsJson))
{
return null;
}

try
{
using var doc = JsonDocument.Parse(argumentsJson);
var result = new Dictionary<string, object?>();
foreach (var property in doc.RootElement.EnumerateObject())
{
result[property.Name] = property.Value.ValueKind switch
{
JsonValueKind.String => property.Value.GetString(),
JsonValueKind.Number => property.Value.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
_ => property.Value.GetRawText()
};
}

return result;
}
catch (JsonException)
{
return null;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ Ensure the agent is registered with '{agentName}' name in the dependency injecti
public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
AgentInvocationContext context,
CreateResponse request,
IReadOnlyList<ChatMessage>? conversationHistory = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
string agentName = GetAgentName(request)!;
Expand All @@ -105,6 +106,11 @@ public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
var options = new ChatClientAgentRunOptions(chatOptions);
var messages = new List<ChatMessage>();

if (conversationHistory is not null)
{
messages.AddRange(conversationHistory);
}

foreach (var inputMessage in request.Input.GetInputMessages())
{
messages.Add(inputMessage.ToChatMessage());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
using Microsoft.Extensions.AI;

namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;

Expand All @@ -28,10 +29,12 @@ internal interface IResponseExecutor
/// </summary>
/// <param name="context">The agent invocation context containing the ID generator and other context information.</param>
/// <param name="request">The create response request.</param>
/// <param name="conversationHistory">Optional prior conversation messages to prepend to the agent's input.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>An async enumerable of streaming response events.</returns>
IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
AgentInvocationContext context,
CreateResponse request,
IReadOnlyList<ChatMessage>? conversationHistory = null,
CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -425,11 +425,28 @@ private async Task ExecuteResponseAsync(string responseId, ResponseState state,
// Create agent invocation context
var context = new AgentInvocationContext(new IdGenerator(responseId: responseId, conversationId: state.Response?.Conversation?.Id));

// Load conversation history if a conversation ID is provided
IReadOnlyList<Extensions.AI.ChatMessage>? conversationHistory = null;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While this change does fix loading of chat history, the code is still broken for other scenarios.
We should really be storing and loading the AgentSession based on the request conversation id. We can then attach a custom per-run ChatHistoryProvider which loads chat history for the session from _conversationStorage and persists new messages into _conversationStorage. If we don't store the session, memory scenarios are broken for all agents exposed via this host.

Happy to work with you get this fixed for all memory scenarios.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @westey-m . I agree.
The AgentSession + custom ChatHistoryProvider approach makes much more sense as a complete solution.Should we treat this PR as a short-term fix and do the proper session integration in a follow-up, or would you prefer to rework this PR to include the full solution?

if (this._conversationStorage is not null && request.Conversation?.Id is not null)
{
var itemsResult = await this._conversationStorage.ListItemsAsync(
request.Conversation.Id,
limit: 100,
order: SortOrder.Ascending,
cancellationToken: linkedCts.Token).ConfigureAwait(false);

var history = ItemResourceConversions.ToChatMessages(itemsResult.Data);
if (history.Count > 0)
{
conversationHistory = history;
}
}

// Collect output items for conversation storage
List<ItemResource> outputItems = [];

// Execute using the injected executor
await foreach (var streamingEvent in this._executor.ExecuteAsync(context, request, linkedCts.Token).ConfigureAwait(false))
await foreach (var streamingEvent in this._executor.ExecuteAsync(context, request, conversationHistory, linkedCts.Token).ConfigureAwait(false))
{
state.AddStreamingEvent(streamingEvent);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1201,6 +1201,75 @@ public async Task CreateResponseStreaming_WithConversationId_DoesNotForwardConve
Assert.Null(mockChatClient.LastChatOptions.ConversationId);
}

/// <summary>
/// Verifies that conversation history is passed to the agent on subsequent requests.
/// This test reproduces the bug described in GitHub issue #3484.
/// </summary>
[Fact]
public async Task CreateResponse_WithConversation_SecondRequestIncludesPriorMessagesAsync()
{
// Arrange
const string AgentName = "memory-agent";
const string Instructions = "You are a helpful assistant.";
const string AgentResponse = "Nice to meet you Alice";

var mockChatClient = new TestHelpers.ConversationMemoryMockChatClient(AgentResponse);
this._httpClient = await this.CreateTestServerWithCustomClientAndConversationsAsync(
AgentName, Instructions, mockChatClient);

// Create a conversation
string createConvJson = System.Text.Json.JsonSerializer.Serialize(
new { metadata = new { agent_id = AgentName } });
using StringContent createConvContent = new(createConvJson, Encoding.UTF8, "application/json");
HttpResponseMessage createConvResponse = await this._httpClient.PostAsync(
new Uri("/v1/conversations", UriKind.Relative), createConvContent);
Assert.True(createConvResponse.IsSuccessStatusCode);

string convJson = await createConvResponse.Content.ReadAsStringAsync();
using var convDoc = System.Text.Json.JsonDocument.Parse(convJson);
string conversationId = convDoc.RootElement.GetProperty("id").GetString()!;

// Act - First message
await this.SendRawResponseAsync(AgentName, "My name is Alice", conversationId, stream: false);

// Act - Second message in same conversation
await this.SendRawResponseAsync(AgentName, "What is my name?", conversationId, stream: false);

// Assert
Assert.Equal(2, mockChatClient.CallHistory.Count);

// First call: should have 1 message (just the user input)
Assert.Single(mockChatClient.CallHistory[0]);
Assert.Equal(ChatRole.User, mockChatClient.CallHistory[0][0].Role);

// Second call: should have 3 messages (prior user + prior assistant + new user)
Assert.Equal(3, mockChatClient.CallHistory[1].Count);
Assert.Equal(ChatRole.User, mockChatClient.CallHistory[1][0].Role);
Assert.Equal(ChatRole.Assistant, mockChatClient.CallHistory[1][1].Role);
Assert.Equal(ChatRole.User, mockChatClient.CallHistory[1][2].Role);
}

private async Task<HttpResponseMessage> SendRawResponseAsync(
string agentName, string input, string conversationId, bool stream)
{
var requestBody = new
{
input,
agent = new { name = agentName },
conversation = conversationId,
stream
};
string json = System.Text.Json.JsonSerializer.Serialize(requestBody);
using StringContent content = new(json, Encoding.UTF8, "application/json");
HttpResponseMessage response = await this._httpClient!.PostAsync(
new Uri($"/{agentName}/v1/responses", UriKind.Relative), content);
Assert.True(response.IsSuccessStatusCode, $"Response failed: {response.StatusCode}");

// Consume the full response body to ensure execution completes
await response.Content.ReadAsStringAsync();
return response;
}

private ResponsesClient CreateResponseClient(string agentName)
{
return new ResponsesClient(
Expand Down Expand Up @@ -1272,6 +1341,29 @@ private async Task<HttpClient> CreateTestServerWithConversationsAsync(string age
return testServer.CreateClient();
}

private async Task<HttpClient> CreateTestServerWithCustomClientAndConversationsAsync(string agentName, string instructions, IChatClient chatClient)
{
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();

builder.Services.AddKeyedSingleton($"chat-client-{agentName}", chatClient);
builder.AddAIAgent(agentName, instructions, chatClientServiceKey: $"chat-client-{agentName}");
builder.AddOpenAIResponses();
builder.AddOpenAIConversations();

this._app = builder.Build();
AIAgent agent = this._app.Services.GetRequiredKeyedService<AIAgent>(agentName);
this._app.MapOpenAIResponses(agent);
this._app.MapOpenAIConversations();

await this._app.StartAsync();

TestServer testServer = this._app.Services.GetRequiredService<IServer>() as TestServer
?? throw new InvalidOperationException("TestServer not found");

return testServer.CreateClient();
}

private async Task<HttpClient> CreateTestServerWithCustomClientAsync(string agentName, string instructions, IChatClient chatClient)
{
WebApplicationBuilder builder = WebApplication.CreateBuilder();
Expand Down
Loading
Loading