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
This commit is contained in:
Victor Dibia
2026-03-02 02:34:25 -08:00
committed by GitHub
Unverified
parent 0d6b9d61a5
commit 9124d51e0e
9 changed files with 388 additions and 16 deletions
@@ -31,6 +31,7 @@ internal sealed class AIAgentResponseExecutor : IResponseExecutor
public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
AgentInvocationContext context,
CreateResponse request,
IReadOnlyList<ChatMessage>? conversationHistory = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// Create options with properties from the request
@@ -51,9 +52,14 @@ internal sealed class AIAgentResponseExecutor : IResponseExecutor
};
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());
@@ -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;
}
}
}
@@ -82,6 +82,7 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor
public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
AgentInvocationContext context,
CreateResponse request,
IReadOnlyList<ChatMessage>? conversationHistory = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
string agentName = GetAgentName(request)!;
@@ -105,6 +106,11 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor
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());
@@ -4,6 +4,7 @@ using System.Collections.Generic;
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;
@@ -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);
}
@@ -425,11 +425,28 @@ internal sealed class InMemoryResponsesService : IResponsesService, IDisposable
// 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;
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);
@@ -1201,6 +1201,75 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
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(
@@ -1272,6 +1341,29 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
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();
@@ -597,6 +597,86 @@ internal static class TestHelpers
}
}
/// <summary>
/// Mock IChatClient that captures the full message list on each call.
/// Used to verify conversation history is passed correctly.
/// </summary>
internal sealed class ConversationMemoryMockChatClient : IChatClient
{
private readonly string _responseText;
/// <summary>Each entry is the messages list received for that call.</summary>
public List<List<ChatMessage>> CallHistory { get; } = [];
public ConversationMemoryMockChatClient(string responseText = "Test response")
{
this._responseText = responseText;
}
public ChatClientMetadata Metadata { get; } = new("Test", new Uri("https://test.example.com"), "test-model");
public Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
this.CallHistory.Add(messages.ToList());
ChatMessage message = new(ChatRole.Assistant, this._responseText);
ChatResponse response = new([message])
{
ModelId = "test-model",
FinishReason = ChatFinishReason.Stop,
Usage = new UsageDetails
{
InputTokenCount = 10,
OutputTokenCount = 5,
TotalTokenCount = 15
}
};
return Task.FromResult(response);
}
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
this.CallHistory.Add(messages.ToList());
await Task.Delay(1, cancellationToken);
string[] words = this._responseText.Split(' ');
for (int i = 0; i < words.Length; i++)
{
string content = i < words.Length - 1 ? words[i] + " " : words[i];
ChatResponseUpdate update = new()
{
Contents = [new TextContent(content)],
Role = ChatRole.Assistant
};
if (i == words.Length - 1)
{
update.Contents.Add(new UsageContent(new UsageDetails
{
InputTokenCount = 10,
OutputTokenCount = 5,
TotalTokenCount = 15
}));
}
yield return update;
}
}
public object? GetService(Type serviceType, object? serviceKey = null) =>
serviceType.IsInstanceOfType(this) ? this : null;
public void Dispose()
{
}
}
/// <summary>
/// Custom content mock implementation of IChatClient that returns custom content based on a provider function.
/// </summary>
@@ -21,6 +21,13 @@ from .models._discovery_models import EntityInfo
logger = logging.getLogger(__name__)
def _get_event_type(event: Any) -> str | None:
"""Safely get the type of an event, handling both objects and dicts."""
if isinstance(event, dict):
return event.get("type")
return getattr(event, "type", None)
class EntityNotFoundError(Exception):
"""Raised when an entity is not found."""
@@ -264,7 +271,7 @@ class AgentFrameworkExecutor:
elif entity_info.type == "workflow":
async for event in self._execute_workflow(entity_obj, request, trace_collector):
# Log request_info event (type='request_info') for debugging HIL flow
if event.type == "request_info":
if _get_event_type(event) == "request_info":
logger.info(
"🔔 [EXECUTOR] request_info event (type='request_info') detected from workflow!"
)
@@ -330,19 +337,22 @@ class AgentFrameworkExecutor:
# Agent must have run() method - use stream=True for streaming
if hasattr(agent, "run") and callable(agent.run):
# Use Agent Framework's run() with stream=True for streaming
# Capture the stream reference so we can call get_final_response()
# after iteration. This triggers result hooks (after_run providers
# like InMemoryHistoryProvider) that persist conversation history.
run_kwargs: dict[str, Any] = {"stream": True}
if session:
async for update in agent.run(user_message, stream=True, session=session):
for trace_event in trace_collector.get_pending_events():
yield trace_event
run_kwargs["session"] = session
yield update
else:
async for update in agent.run(user_message, stream=True):
for trace_event in trace_collector.get_pending_events():
yield trace_event
stream = agent.run(user_message, **run_kwargs)
async for update in stream:
for trace_event in trace_collector.get_pending_events():
yield trace_event
yield update
yield update
# Finalize stream to trigger result hooks (saves conversation history)
await stream.get_final_response()
else:
raise ValueError("Agent must implement run() method")
@@ -471,7 +481,7 @@ class AgentFrameworkExecutor:
checkpoint_storage=checkpoint_storage,
):
# Enrich new request_info events that may come from subsequent HIL requests
if event.type == "request_info":
if _get_event_type(event) == "request_info":
self._enrich_request_info_event_with_response_schema(event, workflow)
for trace_event in trace_collector.get_pending_events():
@@ -493,7 +503,7 @@ class AgentFrameworkExecutor:
checkpoint_id=checkpoint_id,
checkpoint_storage=checkpoint_storage,
):
if event.type == "request_info":
if _get_event_type(event) == "request_info":
self._enrich_request_info_event_with_response_schema(event, workflow)
for trace_event in trace_collector.get_pending_events():
@@ -517,7 +527,7 @@ class AgentFrameworkExecutor:
parsed_input = await self._parse_workflow_input(workflow, request.input)
async for event in workflow.run(parsed_input, stream=True, checkpoint_storage=checkpoint_storage):
if event.type == "request_info":
if _get_event_type(event) == "request_info":
self._enrich_request_info_event_with_response_schema(event, workflow)
for trace_event in trace_collector.get_pending_events():
@@ -741,6 +741,51 @@ async def test_full_pipeline_workflow_output_event_serialization():
assert len(output_events) >= 3, f"Expected 3+ output events for yield_output calls, got {len(output_events)}"
async def test_workflow_error_yields_dict_event_without_crash():
"""Test that workflow errors don't crash execute_entity (#3983).
When a workflow raises an exception, _execute_workflow yields a raw dict
{"type": "error", ...}. The execute_entity caller must handle both dict
events and object events without crashing on attribute access.
"""
from unittest.mock import AsyncMock, MagicMock
from agent_framework_devui.models._discovery_models import EntityInfo
discovery = MagicMock(spec=EntityDiscovery)
mapper = MessageMapper()
executor = AgentFrameworkExecutor(discovery, mapper)
entity_info = EntityInfo(id="bad_wf", name="bad_wf", type="workflow", framework="agent_framework")
discovery.get_entity_info.return_value = entity_info
# Mock workflow whose run() raises
mock_workflow = MagicMock()
mock_workflow.name = "bad_wf"
def failing_run(*args, **kwargs):
raise RuntimeError("Sorry, something went wrong.")
mock_workflow.run = failing_run
discovery.load_entity = AsyncMock(return_value=mock_workflow)
request = AgentFrameworkRequest(
model="test",
input="hello",
metadata={"entity_id": "bad_wf"},
)
events = []
# This should NOT raise AttributeError: 'dict' object has no attribute 'type'
async for event in executor.execute_entity("bad_wf", request):
events.append(event)
# Should get at least one error event
assert len(events) > 0
error_events = [e for e in events if isinstance(e, dict) and e.get("type") == "error"]
assert len(error_events) > 0, f"Expected error dict events, got: {events}"
if __name__ == "__main__":
# Simple test runner
async def run_tests():