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 @@ public WorkflowHostAgent(Workflow workflow, string? id = null, string? name = nu ? InProcessExecution.Concurrent : InProcessExecution.OffThread); this._checkpointManager = checkpointManager; + this._includeExceptionDetails = includeExceptionDetails; this._id = id; this.Name = name; @@ -61,10 +63,10 @@ private async ValueTask ValidateWorkflowAsync() 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 AIAgent AsAgent( 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 @@ public WorkflowThread(Workflow workflow, string runId, IWorkflowExecutionEnviron 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 @@ 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 +92,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 +157,29 @@ 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) + { + 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(); + } +}