Skip to content

Commit 9124d51

Browse files
authored
Python: .NET: Fix .NET conversation memory in DevUI (#3484) (#4294)
* Fix .NET conversation memory in DevUI (#3484) * formatting fixes * fix memory regression in python devui , fix for #4123 * Fix for #3983: Added _get_event_type() helper that safely accesses event type on both objects (.type) and dicts (.get("type")). Replaced all 4 bare event.type accesses in _executor.py (lines 267, 477, 499, 523). Root cause: PR #3690 changed event.__class__.__name__ == "RequestInfoEvent" (safe) to event.type == "request_info" (crashes on dicts), but _execute_workflow still yields raw dicts on error paths. Test: test_workflow_error_yields_dict_event_without_crash — mocks a workflow that raises, verifies execute_entity consumes the dict error events without crashing. * format fixes * lint fixes
1 parent 0d6b9d6 commit 9124d51

9 files changed

Lines changed: 388 additions & 16 deletions

File tree

dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public AIAgentResponseExecutor(AIAgent agent)
3131
public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
3232
AgentInvocationContext context,
3333
CreateResponse request,
34+
IReadOnlyList<ChatMessage>? conversationHistory = null,
3435
[EnumeratorCancellation] CancellationToken cancellationToken = default)
3536
{
3637
// Create options with properties from the request
@@ -51,9 +52,14 @@ public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
5152
};
5253
var options = new ChatClientAgentRunOptions(chatOptions);
5354

54-
// Convert input to chat messages
55+
// Convert input to chat messages, prepending conversation history if available
5556
var messages = new List<ChatMessage>();
5657

58+
if (conversationHistory is not null)
59+
{
60+
messages.AddRange(conversationHistory);
61+
}
62+
5763
foreach (var inputMessage in request.Input.GetInputMessages())
5864
{
5965
messages.Add(inputMessage.ToChatMessage());
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System.Collections.Generic;
4+
using System.Text.Json;
5+
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
6+
using Microsoft.Extensions.AI;
7+
8+
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Converters;
9+
10+
/// <summary>
11+
/// Converts stored <see cref="ItemResource"/> objects back to <see cref="ChatMessage"/> objects
12+
/// for injecting conversation history into agent execution.
13+
/// </summary>
14+
internal static class ItemResourceConversions
15+
{
16+
/// <summary>
17+
/// Converts a sequence of <see cref="ItemResource"/> items to a list of <see cref="ChatMessage"/> objects.
18+
/// Only converts message, function call, and function result items. Other item types are skipped.
19+
/// </summary>
20+
public static List<ChatMessage> ToChatMessages(IEnumerable<ItemResource> items)
21+
{
22+
var messages = new List<ChatMessage>();
23+
24+
foreach (var item in items)
25+
{
26+
switch (item)
27+
{
28+
case ResponsesUserMessageItemResource userMsg:
29+
messages.Add(new ChatMessage(ChatRole.User, ConvertContents(userMsg.Content)));
30+
break;
31+
32+
case ResponsesAssistantMessageItemResource assistantMsg:
33+
messages.Add(new ChatMessage(ChatRole.Assistant, ConvertContents(assistantMsg.Content)));
34+
break;
35+
36+
case ResponsesSystemMessageItemResource systemMsg:
37+
messages.Add(new ChatMessage(ChatRole.System, ConvertContents(systemMsg.Content)));
38+
break;
39+
40+
case ResponsesDeveloperMessageItemResource developerMsg:
41+
messages.Add(new ChatMessage(new ChatRole("developer"), ConvertContents(developerMsg.Content)));
42+
break;
43+
44+
case FunctionToolCallItemResource funcCall:
45+
var arguments = ParseArguments(funcCall.Arguments);
46+
messages.Add(new ChatMessage(ChatRole.Assistant,
47+
[
48+
new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)
49+
]));
50+
break;
51+
52+
case FunctionToolCallOutputItemResource funcOutput:
53+
messages.Add(new ChatMessage(ChatRole.Tool,
54+
[
55+
new FunctionResultContent(funcOutput.CallId, funcOutput.Output)
56+
]));
57+
break;
58+
59+
// Skip all other item types (reasoning, executor_action, web_search, etc.)
60+
// They are not relevant for conversation context.
61+
}
62+
}
63+
64+
return messages;
65+
}
66+
67+
private static List<AIContent> ConvertContents(List<ItemContent> contents)
68+
{
69+
var result = new List<AIContent>();
70+
foreach (var content in contents)
71+
{
72+
var aiContent = ItemContentConverter.ToAIContent(content);
73+
if (aiContent is not null)
74+
{
75+
result.Add(aiContent);
76+
}
77+
}
78+
79+
return result;
80+
}
81+
82+
private static Dictionary<string, object?>? ParseArguments(string? argumentsJson)
83+
{
84+
if (string.IsNullOrEmpty(argumentsJson))
85+
{
86+
return null;
87+
}
88+
89+
try
90+
{
91+
using var doc = JsonDocument.Parse(argumentsJson);
92+
var result = new Dictionary<string, object?>();
93+
foreach (var property in doc.RootElement.EnumerateObject())
94+
{
95+
result[property.Name] = property.Value.ValueKind switch
96+
{
97+
JsonValueKind.String => property.Value.GetString(),
98+
JsonValueKind.Number => property.Value.GetDouble(),
99+
JsonValueKind.True => true,
100+
JsonValueKind.False => false,
101+
JsonValueKind.Null => null,
102+
_ => property.Value.GetRawText()
103+
};
104+
}
105+
106+
return result;
107+
}
108+
catch (JsonException)
109+
{
110+
return null;
111+
}
112+
}
113+
}

dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ Ensure the agent is registered with '{agentName}' name in the dependency injecti
8282
public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
8383
AgentInvocationContext context,
8484
CreateResponse request,
85+
IReadOnlyList<ChatMessage>? conversationHistory = null,
8586
[EnumeratorCancellation] CancellationToken cancellationToken = default)
8687
{
8788
string agentName = GetAgentName(request)!;
@@ -105,6 +106,11 @@ public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
105106
var options = new ChatClientAgentRunOptions(chatOptions);
106107
var messages = new List<ChatMessage>();
107108

109+
if (conversationHistory is not null)
110+
{
111+
messages.AddRange(conversationHistory);
112+
}
113+
108114
foreach (var inputMessage in request.Input.GetInputMessages())
109115
{
110116
messages.Add(inputMessage.ToChatMessage());

dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IResponseExecutor.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.Threading;
55
using System.Threading.Tasks;
66
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
7+
using Microsoft.Extensions.AI;
78

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

@@ -28,10 +29,12 @@ internal interface IResponseExecutor
2829
/// </summary>
2930
/// <param name="context">The agent invocation context containing the ID generator and other context information.</param>
3031
/// <param name="request">The create response request.</param>
32+
/// <param name="conversationHistory">Optional prior conversation messages to prepend to the agent's input.</param>
3133
/// <param name="cancellationToken">Cancellation token.</param>
3234
/// <returns>An async enumerable of streaming response events.</returns>
3335
IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
3436
AgentInvocationContext context,
3537
CreateResponse request,
38+
IReadOnlyList<ChatMessage>? conversationHistory = null,
3639
CancellationToken cancellationToken = default);
3740
}

dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -425,11 +425,28 @@ private async Task ExecuteResponseAsync(string responseId, ResponseState state,
425425
// Create agent invocation context
426426
var context = new AgentInvocationContext(new IdGenerator(responseId: responseId, conversationId: state.Response?.Conversation?.Id));
427427

428+
// Load conversation history if a conversation ID is provided
429+
IReadOnlyList<Extensions.AI.ChatMessage>? conversationHistory = null;
430+
if (this._conversationStorage is not null && request.Conversation?.Id is not null)
431+
{
432+
var itemsResult = await this._conversationStorage.ListItemsAsync(
433+
request.Conversation.Id,
434+
limit: 100,
435+
order: SortOrder.Ascending,
436+
cancellationToken: linkedCts.Token).ConfigureAwait(false);
437+
438+
var history = ItemResourceConversions.ToChatMessages(itemsResult.Data);
439+
if (history.Count > 0)
440+
{
441+
conversationHistory = history;
442+
}
443+
}
444+
428445
// Collect output items for conversation storage
429446
List<ItemResource> outputItems = [];
430447

431448
// Execute using the injected executor
432-
await foreach (var streamingEvent in this._executor.ExecuteAsync(context, request, linkedCts.Token).ConfigureAwait(false))
449+
await foreach (var streamingEvent in this._executor.ExecuteAsync(context, request, conversationHistory, linkedCts.Token).ConfigureAwait(false))
433450
{
434451
state.AddStreamingEvent(streamingEvent);
435452

dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1201,6 +1201,75 @@ public async Task CreateResponseStreaming_WithConversationId_DoesNotForwardConve
12011201
Assert.Null(mockChatClient.LastChatOptions.ConversationId);
12021202
}
12031203

1204+
/// <summary>
1205+
/// Verifies that conversation history is passed to the agent on subsequent requests.
1206+
/// This test reproduces the bug described in GitHub issue #3484.
1207+
/// </summary>
1208+
[Fact]
1209+
public async Task CreateResponse_WithConversation_SecondRequestIncludesPriorMessagesAsync()
1210+
{
1211+
// Arrange
1212+
const string AgentName = "memory-agent";
1213+
const string Instructions = "You are a helpful assistant.";
1214+
const string AgentResponse = "Nice to meet you Alice";
1215+
1216+
var mockChatClient = new TestHelpers.ConversationMemoryMockChatClient(AgentResponse);
1217+
this._httpClient = await this.CreateTestServerWithCustomClientAndConversationsAsync(
1218+
AgentName, Instructions, mockChatClient);
1219+
1220+
// Create a conversation
1221+
string createConvJson = System.Text.Json.JsonSerializer.Serialize(
1222+
new { metadata = new { agent_id = AgentName } });
1223+
using StringContent createConvContent = new(createConvJson, Encoding.UTF8, "application/json");
1224+
HttpResponseMessage createConvResponse = await this._httpClient.PostAsync(
1225+
new Uri("/v1/conversations", UriKind.Relative), createConvContent);
1226+
Assert.True(createConvResponse.IsSuccessStatusCode);
1227+
1228+
string convJson = await createConvResponse.Content.ReadAsStringAsync();
1229+
using var convDoc = System.Text.Json.JsonDocument.Parse(convJson);
1230+
string conversationId = convDoc.RootElement.GetProperty("id").GetString()!;
1231+
1232+
// Act - First message
1233+
await this.SendRawResponseAsync(AgentName, "My name is Alice", conversationId, stream: false);
1234+
1235+
// Act - Second message in same conversation
1236+
await this.SendRawResponseAsync(AgentName, "What is my name?", conversationId, stream: false);
1237+
1238+
// Assert
1239+
Assert.Equal(2, mockChatClient.CallHistory.Count);
1240+
1241+
// First call: should have 1 message (just the user input)
1242+
Assert.Single(mockChatClient.CallHistory[0]);
1243+
Assert.Equal(ChatRole.User, mockChatClient.CallHistory[0][0].Role);
1244+
1245+
// Second call: should have 3 messages (prior user + prior assistant + new user)
1246+
Assert.Equal(3, mockChatClient.CallHistory[1].Count);
1247+
Assert.Equal(ChatRole.User, mockChatClient.CallHistory[1][0].Role);
1248+
Assert.Equal(ChatRole.Assistant, mockChatClient.CallHistory[1][1].Role);
1249+
Assert.Equal(ChatRole.User, mockChatClient.CallHistory[1][2].Role);
1250+
}
1251+
1252+
private async Task<HttpResponseMessage> SendRawResponseAsync(
1253+
string agentName, string input, string conversationId, bool stream)
1254+
{
1255+
var requestBody = new
1256+
{
1257+
input,
1258+
agent = new { name = agentName },
1259+
conversation = conversationId,
1260+
stream
1261+
};
1262+
string json = System.Text.Json.JsonSerializer.Serialize(requestBody);
1263+
using StringContent content = new(json, Encoding.UTF8, "application/json");
1264+
HttpResponseMessage response = await this._httpClient!.PostAsync(
1265+
new Uri($"/{agentName}/v1/responses", UriKind.Relative), content);
1266+
Assert.True(response.IsSuccessStatusCode, $"Response failed: {response.StatusCode}");
1267+
1268+
// Consume the full response body to ensure execution completes
1269+
await response.Content.ReadAsStringAsync();
1270+
return response;
1271+
}
1272+
12041273
private ResponsesClient CreateResponseClient(string agentName)
12051274
{
12061275
return new ResponsesClient(
@@ -1272,6 +1341,29 @@ private async Task<HttpClient> CreateTestServerWithConversationsAsync(string age
12721341
return testServer.CreateClient();
12731342
}
12741343

1344+
private async Task<HttpClient> CreateTestServerWithCustomClientAndConversationsAsync(string agentName, string instructions, IChatClient chatClient)
1345+
{
1346+
WebApplicationBuilder builder = WebApplication.CreateBuilder();
1347+
builder.WebHost.UseTestServer();
1348+
1349+
builder.Services.AddKeyedSingleton($"chat-client-{agentName}", chatClient);
1350+
builder.AddAIAgent(agentName, instructions, chatClientServiceKey: $"chat-client-{agentName}");
1351+
builder.AddOpenAIResponses();
1352+
builder.AddOpenAIConversations();
1353+
1354+
this._app = builder.Build();
1355+
AIAgent agent = this._app.Services.GetRequiredKeyedService<AIAgent>(agentName);
1356+
this._app.MapOpenAIResponses(agent);
1357+
this._app.MapOpenAIConversations();
1358+
1359+
await this._app.StartAsync();
1360+
1361+
TestServer testServer = this._app.Services.GetRequiredService<IServer>() as TestServer
1362+
?? throw new InvalidOperationException("TestServer not found");
1363+
1364+
return testServer.CreateClient();
1365+
}
1366+
12751367
private async Task<HttpClient> CreateTestServerWithCustomClientAsync(string agentName, string instructions, IChatClient chatClient)
12761368
{
12771369
WebApplicationBuilder builder = WebApplication.CreateBuilder();

0 commit comments

Comments
 (0)