From 99fac4ca56486cb3bde2540d967163cb7b928014 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 8 Jan 2026 12:34:05 -0500 Subject: [PATCH] .NET: fix: Expose WorkflowErrorEvent as ErrorContent (#2762) * 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. * feat: Add a way to show/suppress exception information --- .../WorkflowErrorEvent.cs | 8 +- .../WorkflowHostAgent.cs | 8 +- .../WorkflowHostingExtensions.cs | 7 +- .../WorkflowThread.cs | 33 ++++- .../WorkflowHostSmokeTests.cs | 114 ++++++++++++++++++ 5 files changed, 159 insertions(+), 11 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/WorkflowHostAgent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs index 8e8012f5bb..4e5ee86070 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs @@ -18,11 +18,12 @@ internal sealed class WorkflowHostAgent : AIAgent private readonly string? _id; private readonly CheckpointManager? _checkpointManager; private readonly IWorkflowExecutionEnvironment _executionEnvironment; + private readonly bool _includeExceptionDetails; private readonly Task _describeTask; private readonly ConcurrentDictionary _assignedRunIds = []; - public WorkflowHostAgent(Workflow workflow, string? id = null, string? name = null, string? description = null, CheckpointManager? checkpointManager = null, IWorkflowExecutionEnvironment? executionEnvironment = null) + public WorkflowHostAgent(Workflow workflow, string? id = null, string? name = null, string? description = null, CheckpointManager? checkpointManager = null, IWorkflowExecutionEnvironment? executionEnvironment = null, bool includeExceptionDetails = false) { this._workflow = Throw.IfNull(workflow); @@ -30,6 +31,7 @@ internal sealed class WorkflowHostAgent : AIAgent ? InProcessExecution.Concurrent : InProcessExecution.OffThread); this._checkpointManager = checkpointManager; + this._includeExceptionDetails = includeExceptionDetails; this._id = id; this.Name = name; @@ -61,10 +63,10 @@ internal sealed class WorkflowHostAgent : AIAgent protocol.ThrowIfNotChatProtocol(); } - public override AgentThread GetNewThread() => new WorkflowThread(this._workflow, this.GenerateNewId(), this._executionEnvironment, this._checkpointManager); + public override AgentThread GetNewThread() => new WorkflowThread(this._workflow, this.GenerateNewId(), this._executionEnvironment, this._checkpointManager, this._includeExceptionDetails); public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) - => new WorkflowThread(this._workflow, serializedThread, this._executionEnvironment, this._checkpointManager, jsonSerializerOptions); + => new WorkflowThread(this._workflow, serializedThread, this._executionEnvironment, this._checkpointManager, this._includeExceptionDetails, jsonSerializerOptions); private ValueTask UpdateThreadAsync(IEnumerable messages, AgentThread? thread = null, CancellationToken cancellationToken = default) { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostingExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostingExtensions.cs index d48e99bf6e..c217039a35 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostingExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostingExtensions.cs @@ -21,6 +21,8 @@ public static class WorkflowHostingExtensions /// Specify the execution environment to use when running the workflows. See /// , and /// for the in-process environments. + /// If , will include + /// in the representing the workflow error. /// public static AIAgent AsAgent( this Workflow workflow, @@ -28,9 +30,10 @@ public static class WorkflowHostingExtensions string? name = null, string? description = null, CheckpointManager? checkpointManager = null, - IWorkflowExecutionEnvironment? executionEnvironment = null) + IWorkflowExecutionEnvironment? executionEnvironment = null, + bool includeExceptionDetails = false) { - return new WorkflowHostAgent(workflow, id, name, description, checkpointManager, executionEnvironment); + return new WorkflowHostAgent(workflow, id, name, description, checkpointManager, executionEnvironment, includeExceptionDetails); } internal static FunctionCallContent ToFunctionCall(this ExternalRequest request) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowThread.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowThread.cs index d27de6bd5c..94144831e0 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; @@ -17,14 +18,16 @@ internal sealed class WorkflowThread : AgentThread { private readonly Workflow _workflow; private readonly IWorkflowExecutionEnvironment _executionEnvironment; + private readonly bool _includeExceptionDetails; private readonly CheckpointManager _checkpointManager; private readonly InMemoryCheckpointManager? _inMemoryCheckpointManager; - public WorkflowThread(Workflow workflow, string runId, IWorkflowExecutionEnvironment executionEnvironment, CheckpointManager? checkpointManager = null) + public WorkflowThread(Workflow workflow, string runId, IWorkflowExecutionEnvironment executionEnvironment, CheckpointManager? checkpointManager = null, bool includeExceptionDetails = false) { this._workflow = Throw.IfNull(workflow); this._executionEnvironment = Throw.IfNull(executionEnvironment); + this._includeExceptionDetails = includeExceptionDetails; // If the user provided an external checkpoint manager, use that, otherwise rely on an in-memory one. // TODO: Implement persist-only-last functionality for in-memory checkpoint manager, to avoid unbounded @@ -35,7 +38,7 @@ internal sealed class WorkflowThread : AgentThread this.MessageStore = new WorkflowMessageStore(); } - public WorkflowThread(Workflow workflow, JsonElement serializedThread, IWorkflowExecutionEnvironment executionEnvironment, CheckpointManager? checkpointManager = null, JsonSerializerOptions? jsonSerializerOptions = null) + public WorkflowThread(Workflow workflow, JsonElement serializedThread, IWorkflowExecutionEnvironment executionEnvironment, CheckpointManager? checkpointManager = null, bool includeExceptionDetails = false, JsonSerializerOptions? jsonSerializerOptions = null) { this._workflow = Throw.IfNull(workflow); this._executionEnvironment = Throw.IfNull(executionEnvironment); @@ -80,7 +83,7 @@ internal sealed class WorkflowThread : AgentThread 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 +92,8 @@ internal sealed class WorkflowThread : AgentThread CreatedAt = DateTimeOffset.UtcNow, MessageId = Guid.NewGuid().ToString("N"), Role = ChatRole.Assistant, - ResponseId = responseId + ResponseId = responseId, + RawRepresentation = raw }; this.MessageStore.AddMessages(update.ToChatMessage()); @@ -153,10 +157,29 @@ internal sealed class WorkflowThread : AgentThread 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) + { + string message = this._includeExceptionDetails + ? exception.Message + : "An error occurred while executing the workflow."; + + ErrorContent errorContent = new(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..be3c96d9f5 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs @@ -0,0 +1,114 @@ +// 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(); + } + + protected override async Task RunCoreAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + return await this.RunStreamingAsync(messages, thread, options, cancellationToken) + .ToAgentRunResponseAsync(cancellationToken); + } + + protected override async IAsyncEnumerable RunCoreStreamingAsync(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(); + } + + [Theory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public async Task Test_AsAgent_ErrorContentStreamedOutAsync(bool includeExceptionDetails, bool failByThrowing) + { + string expectedMessage = !failByThrowing || includeExceptionDetails + ? "Simulated agent failure." + : "An error occurred while executing the workflow."; + + // Arrange is done by the caller. + Workflow workflow = CreateWorkflow(failByThrowing); + + // Act + List updates = await workflow.AsAgent("WorkflowAgent", includeExceptionDetails: includeExceptionDetails) + .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(expectedMessage); + hadErrorContent = true; + } + } + + hadErrorContent.Should().BeTrue(); + } +}