From fcd90d2951b68d6bd6b3d0e9f17c93798acdb44c Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 10 Dec 2025 15:34:36 -0500 Subject: [PATCH] fix: Expose WorkflowErrorEvent as ErrorContent When hosted using .AsAgent(), Workflows were not exposing inner errors coming as Exceptions (through the WorkflowErrorEvent) The fix is to convert their message to an ErrorContent on the way out, rather than rely on the default "empty update" to collect the raw event. --- .../WorkflowErrorEvent.cs | 8 +- .../WorkflowThread.cs | 23 +++- .../WorkflowHostSmokeTests.cs | 112 ++++++++++++++++++ 3 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowErrorEvent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowErrorEvent.cs index 7af7efd0b9..aec9e8130c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowErrorEvent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowErrorEvent.cs @@ -10,4 +10,10 @@ namespace Microsoft.Agents.AI.Workflows; /// /// Optionally, the representing the error. /// -public class WorkflowErrorEvent(Exception? e) : WorkflowEvent(e); +public class WorkflowErrorEvent(Exception? e) : WorkflowEvent(e) +{ + /// + /// Gets the exception that caused the current operation to fail, if one occurred. + /// + public Exception? Exception => this.Data as Exception; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowThread.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowThread.cs index d27de6bd5c..10d82e484f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowThread.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowThread.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Reflection; using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading; @@ -80,7 +81,7 @@ public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptio return marshaller.Marshal(info); } - public AgentRunResponseUpdate CreateUpdate(string responseId, params AIContent[] parts) + public AgentRunResponseUpdate CreateUpdate(string responseId, object raw, params AIContent[] parts) { Throw.IfNullOrEmpty(parts); @@ -89,7 +90,8 @@ public AgentRunResponseUpdate CreateUpdate(string responseId, params AIContent[] CreatedAt = DateTimeOffset.UtcNow, MessageId = Guid.NewGuid().ToString("N"), Role = ChatRole.Assistant, - ResponseId = responseId + ResponseId = responseId, + RawRepresentation = raw }; this.MessageStore.AddMessages(update.ToChatMessage()); @@ -153,10 +155,25 @@ IAsyncEnumerable InvokeStageAsync( case RequestInfoEvent requestInfo: FunctionCallContent fcContent = requestInfo.Request.ToFunctionCall(); - AgentRunResponseUpdate update = this.CreateUpdate(this.LastResponseId, fcContent); + AgentRunResponseUpdate update = this.CreateUpdate(this.LastResponseId, evt, fcContent); yield return update; break; + case WorkflowErrorEvent workflowError: + Exception? exception = workflowError.Exception; + if (exception is TargetInvocationException tie && tie.InnerException != null) + { + exception = tie.InnerException; + } + + if (exception != null) + { + ErrorContent errorContent = new(exception.Message); + yield return this.CreateUpdate(this.LastResponseId, evt, errorContent); + } + + break; + case SuperStepCompletedEvent stepCompleted: this.LastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint; goto default; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs new file mode 100644 index 0000000000..133fc78e47 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +public sealed class ExpectedException : Exception +{ + public ExpectedException(string message) + : base(message) + { + } + + public ExpectedException() : base() + { + } + + public ExpectedException(string? message, Exception? innerException) : base(message, innerException) + { + } +} + +public class WorkflowHostSmokeTests +{ + private sealed class AlwaysFailsAIAgent(bool failByThrowing) : AIAgent + { + private sealed class Thread : InMemoryAgentThread + { + public Thread() { } + + public Thread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) + : base(serializedThread, jsonSerializerOptions) + { } + } + + public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) + { + return new Thread(serializedThread, jsonSerializerOptions); + } + + public override AgentThread GetNewThread() + { + return new Thread(); + } + + public override async Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + return await this.RunStreamingAsync(messages, thread, options, cancellationToken) + .ToAgentRunResponseAsync(cancellationToken); + } + + public override async IAsyncEnumerable RunStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + const string ErrorMessage = "Simulated agent failure."; + if (failByThrowing) + { + throw new ExpectedException(ErrorMessage); + } + + yield return new AgentRunResponseUpdate(ChatRole.Assistant, [new ErrorContent(ErrorMessage)]); + } + } + + private static Workflow CreateWorkflow(bool failByThrowing) + { + ExecutorBinding agent = new AlwaysFailsAIAgent(failByThrowing).BindAsExecutor(emitEvents: true); + + return new WorkflowBuilder(agent).Build(); + } + + private async static Task InvokeAsAgentAndProcessResponseAsync(Workflow workflow) + { + // Arrange is done by the caller. + + // Act + List updates = await workflow.AsAgent("WorkflowAgent") + .RunStreamingAsync(new ChatMessage(ChatRole.User, "Hello")) + .ToListAsync(); + + // Assert + bool hadErrorContent = false; + foreach (AgentRunResponseUpdate update in updates) + { + if (update.Contents.Any()) + { + // We should expect a single update which contains the error content. + update.Contents.Should().ContainSingle() + .Which.Should().BeOfType() + .Which.Message.Should().Be("Simulated agent failure."); + hadErrorContent = true; + } + } + + hadErrorContent.Should().BeTrue(); + } + + [Fact] + public Task Test_AsAgent_ErrorContentStreamedOutAsync() + => InvokeAsAgentAndProcessResponseAsync(CreateWorkflow(failByThrowing: false)); + + [Fact] + public Task Test_AsAgent_ExceptionToErrorContentAsync() + => InvokeAsAgentAndProcessResponseAsync(CreateWorkflow(failByThrowing: true)); +}