From 798d868597c48fbbf2fe42f4f7d46b55110c71c3 Mon Sep 17 00:00:00 2001 From: Victor Dibia Date: Wed, 25 Feb 2026 20:26:43 -0800 Subject: [PATCH 1/6] Fix .NET conversation memory in DevUI (#3484) --- .../Responses/AIAgentResponseExecutor.cs | 8 +- .../Converters/ItemResourceConversions.cs | 113 ++++++++++++++++++ .../Responses/HostedAgentResponseExecutor.cs | 6 + .../Responses/IResponseExecutor.cs | 3 + .../Responses/InMemoryResponsesService.cs | 19 ++- .../OpenAIResponsesIntegrationTests.cs | 92 ++++++++++++++ .../TestHelpers.cs | 80 +++++++++++++ 7 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemResourceConversions.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs index e3706bee1c..e2e07d00b7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs @@ -31,6 +31,7 @@ public AIAgentResponseExecutor(AIAgent agent) public async IAsyncEnumerable ExecuteAsync( AgentInvocationContext context, CreateResponse request, + IReadOnlyList? conversationHistory = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { // Create options with properties from the request @@ -51,9 +52,14 @@ public async IAsyncEnumerable 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(); + if (conversationHistory is not null) + { + messages.AddRange(conversationHistory); + } + foreach (var inputMessage in request.Input.GetInputMessages()) { messages.Add(inputMessage.ToChatMessage()); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemResourceConversions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemResourceConversions.cs new file mode 100644 index 0000000000..fa28f92cbe --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemResourceConversions.cs @@ -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; + +/// +/// Converts stored objects back to objects +/// for injecting conversation history into agent execution. +/// +internal static class ItemResourceConversions +{ + /// + /// Converts a sequence of items to a list of objects. + /// Only converts message, function call, and function result items. Other item types are skipped. + /// + public static List ToChatMessages(IEnumerable items) + { + var messages = new List(); + + 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 ConvertContents(List contents) + { + var result = new List(); + foreach (var content in contents) + { + var aiContent = ItemContentConverter.ToAIContent(content); + if (aiContent is not null) + { + result.Add(aiContent); + } + } + + return result; + } + + private static Dictionary? ParseArguments(string? argumentsJson) + { + if (string.IsNullOrEmpty(argumentsJson)) + { + return null; + } + + try + { + using var doc = JsonDocument.Parse(argumentsJson); + var result = new Dictionary(); + 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; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs index 78cf89b970..ad98e9e755 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs @@ -82,6 +82,7 @@ Ensure the agent is registered with '{agentName}' name in the dependency injecti public async IAsyncEnumerable ExecuteAsync( AgentInvocationContext context, CreateResponse request, + IReadOnlyList? conversationHistory = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { string agentName = GetAgentName(request)!; @@ -105,6 +106,11 @@ public async IAsyncEnumerable ExecuteAsync( var options = new ChatClientAgentRunOptions(chatOptions); var messages = new List(); + if (conversationHistory is not null) + { + messages.AddRange(conversationHistory); + } + foreach (var inputMessage in request.Input.GetInputMessages()) { messages.Add(inputMessage.ToChatMessage()); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IResponseExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IResponseExecutor.cs index b96879f4cc..84f47af3ed 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IResponseExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IResponseExecutor.cs @@ -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; @@ -28,10 +29,12 @@ internal interface IResponseExecutor /// /// The agent invocation context containing the ID generator and other context information. /// The create response request. + /// Optional prior conversation messages to prepend to the agent's input. /// Cancellation token. /// An async enumerable of streaming response events. IAsyncEnumerable ExecuteAsync( AgentInvocationContext context, CreateResponse request, + IReadOnlyList? conversationHistory = null, CancellationToken cancellationToken = default); } 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..129b9c7f5e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs @@ -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? 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: OpenAI.Models.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 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); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs index 2dd5b85e5f..0b9441d633 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs @@ -1201,6 +1201,75 @@ public async Task CreateResponseStreaming_WithConversationId_DoesNotForwardConve Assert.Null(mockChatClient.LastChatOptions.ConversationId); } + /// + /// Verifies that conversation history is passed to the agent on subsequent requests. + /// This test reproduces the bug described in GitHub issue #3484. + /// + [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 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 @@ private async Task CreateTestServerWithConversationsAsync(string age return testServer.CreateClient(); } + private async Task 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(agentName); + this._app.MapOpenAIResponses(agent); + this._app.MapOpenAIConversations(); + + await this._app.StartAsync(); + + TestServer testServer = this._app.Services.GetRequiredService() as TestServer + ?? throw new InvalidOperationException("TestServer not found"); + + return testServer.CreateClient(); + } + private async Task CreateTestServerWithCustomClientAsync(string agentName, string instructions, IChatClient chatClient) { WebApplicationBuilder builder = WebApplication.CreateBuilder(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/TestHelpers.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/TestHelpers.cs index 191da528a4..198e65629e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/TestHelpers.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/TestHelpers.cs @@ -597,6 +597,86 @@ public void Dispose() } } + /// + /// Mock IChatClient that captures the full message list on each call. + /// Used to verify conversation history is passed correctly. + /// + internal sealed class ConversationMemoryMockChatClient : IChatClient + { + private readonly string _responseText; + + /// Each entry is the messages list received for that call. + public List> 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 GetResponseAsync( + IEnumerable 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 GetStreamingResponseAsync( + IEnumerable 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() + { + } + } + /// /// Custom content mock implementation of IChatClient that returns custom content based on a provider function. /// From cee4f2390af6fae1c35280761f2b21fd83d8996c Mon Sep 17 00:00:00 2001 From: Victor Dibia Date: Wed, 25 Feb 2026 21:18:24 -0800 Subject: [PATCH 2/6] formatting fixes --- .../Responses/Converters/ItemResourceConversions.cs | 2 +- .../Responses/InMemoryResponsesService.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemResourceConversions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemResourceConversions.cs index fa28f92cbe..b9a935d54d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemResourceConversions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemResourceConversions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using System.Text.Json; 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 129b9c7f5e..6224120ac9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs @@ -432,7 +432,7 @@ private async Task ExecuteResponseAsync(string responseId, ResponseState state, var itemsResult = await this._conversationStorage.ListItemsAsync( request.Conversation.Id, limit: 100, - order: OpenAI.Models.SortOrder.Ascending, + order: SortOrder.Ascending, cancellationToken: linkedCts.Token).ConfigureAwait(false); var history = ItemResourceConversions.ToChatMessages(itemsResult.Data); From 52227107ca3241523f40fe61b9703023ef474972 Mon Sep 17 00:00:00 2001 From: Victor Dibia Date: Wed, 25 Feb 2026 22:20:47 -0800 Subject: [PATCH 3/6] fix memory regression in python devui , fix for #4123 --- .../devui/agent_framework_devui/_executor.py | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/python/packages/devui/agent_framework_devui/_executor.py b/python/packages/devui/agent_framework_devui/_executor.py index e019917630..84b18bda8c 100644 --- a/python/packages/devui/agent_framework_devui/_executor.py +++ b/python/packages/devui/agent_framework_devui/_executor.py @@ -330,19 +330,22 @@ async def _execute_agent( # 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") From 24f19ba41fd2db6afefbd5050a6c1903a2bf6585 Mon Sep 17 00:00:00 2001 From: Victor Dibia Date: Wed, 25 Feb 2026 22:36:34 -0800 Subject: [PATCH 4/6] 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). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../devui/agent_framework_devui/_executor.py | 15 +++++-- .../devui/tests/devui/test_execution.py | 45 +++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/python/packages/devui/agent_framework_devui/_executor.py b/python/packages/devui/agent_framework_devui/_executor.py index 84b18bda8c..1b1b77162a 100644 --- a/python/packages/devui/agent_framework_devui/_executor.py +++ b/python/packages/devui/agent_framework_devui/_executor.py @@ -21,6 +21,13 @@ 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 @@ async def execute_entity(self, entity_id: str, request: AgentFrameworkRequest) - 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!" ) @@ -474,7 +481,7 @@ async def _execute_workflow( 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(): @@ -496,7 +503,7 @@ async def _execute_workflow( 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(): @@ -520,7 +527,7 @@ async def _execute_workflow( 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(): diff --git a/python/packages/devui/tests/devui/test_execution.py b/python/packages/devui/tests/devui/test_execution.py index a7ac622c75..a2e088bdaa 100644 --- a/python/packages/devui/tests/devui/test_execution.py +++ b/python/packages/devui/tests/devui/test_execution.py @@ -741,6 +741,51 @@ async def process(self, input_text: str, ctx: WorkflowContext[Any, Any]) -> None 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" + + async 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(): From 3bc06d0c6db659fa8d43828c662546da82364e19 Mon Sep 17 00:00:00 2001 From: Victor Dibia Date: Thu, 26 Feb 2026 09:30:27 -0800 Subject: [PATCH 5/6] format fixes --- python/packages/devui/tests/devui/test_execution.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/packages/devui/tests/devui/test_execution.py b/python/packages/devui/tests/devui/test_execution.py index a2e088bdaa..4d0436a314 100644 --- a/python/packages/devui/tests/devui/test_execution.py +++ b/python/packages/devui/tests/devui/test_execution.py @@ -763,7 +763,7 @@ async def test_workflow_error_yields_dict_event_without_crash(): mock_workflow = MagicMock() mock_workflow.name = "bad_wf" - async def failing_run(*args, **kwargs): + def failing_run(*args, **kwargs): raise RuntimeError("Sorry, something went wrong.") mock_workflow.run = failing_run From 902d8e1a036be2ede07c96df890771464ba026e6 Mon Sep 17 00:00:00 2001 From: Victor Dibia Date: Thu, 26 Feb 2026 11:46:52 -0800 Subject: [PATCH 6/6] lint fixes --- python/packages/devui/agent_framework_devui/_deployment.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/python/packages/devui/agent_framework_devui/_deployment.py b/python/packages/devui/agent_framework_devui/_deployment.py index 45f99a315a..db2de27ecf 100644 --- a/python/packages/devui/agent_framework_devui/_deployment.py +++ b/python/packages/devui/agent_framework_devui/_deployment.py @@ -92,8 +92,7 @@ async def deploy(self, config: DeploymentConfig, entity_path: Path) -> AsyncGene break # Get event from queue with short timeout - event = await asyncio.wait_for(event_queue.get(), timeout=0.1) - yield event + yield await asyncio.wait_for(event_queue.get(), timeout=0.1) except asyncio.TimeoutError: # No event in queue, continue waiting continue