From ce9e66f1b58d9fa2fed0583fc14bd850bce0e01f Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Mon, 26 Jan 2026 10:49:20 -0500 Subject: [PATCH 1/2] Rename WorkflowOutputEvent.SourceId to ExecutorId for Python consistency - Rename SourceId property to ExecutorId in WorkflowOutputEvent - Add [Obsolete] SourceId property for backward compatibility - Update all test usages to use ExecutorId Resolves part of #2938 --- .../WorkflowOutputEvent.cs | 12 +++++++++--- .../Sample/04_Simple_Workflow_ExternalRequest.cs | 2 +- .../Sample/05_Simple_Workflow_Checkpointing.cs | 2 +- .../Sample/13_Subworkflow_Checkpointing.cs | 2 +- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowOutputEvent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowOutputEvent.cs index 760f2ae029..8634c89271 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowOutputEvent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowOutputEvent.cs @@ -10,15 +10,21 @@ namespace Microsoft.Agents.AI.Workflows; /// public sealed class WorkflowOutputEvent : WorkflowEvent { - internal WorkflowOutputEvent(object data, string sourceId) : base(data) + internal WorkflowOutputEvent(object data, string executorId) : base(data) { - this.SourceId = sourceId; + this.ExecutorId = executorId; } /// /// The unique identifier of the executor that yielded this output. /// - public string SourceId { get; } + public string ExecutorId { get; } + + /// + /// The unique identifier of the executor that yielded this output. + /// + [Obsolete("Use ExecutorId instead.")] + public string SourceId => this.ExecutorId; /// /// Determines whether the underlying data is of the specified type or a derived type. diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/04_Simple_Workflow_ExternalRequest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/04_Simple_Workflow_ExternalRequest.cs index cc417e727a..1bda1e7a77 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/04_Simple_Workflow_ExternalRequest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/04_Simple_Workflow_ExternalRequest.cs @@ -45,7 +45,7 @@ public static async ValueTask RunAsync(TextWriter writer, Func RunAsync(TextWriter writer, string { foreach (ChatMessage message in messages) { - writer.WriteLine($"{output.SourceId}: {message.Text}"); + writer.WriteLine($"{output.ExecutorId}: {message.Text}"); } } else From cd0c68d4c6953395296072fa63f6d858ae2740dc Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Mon, 26 Jan 2026 11:20:54 -0500 Subject: [PATCH 2/2] Unify AgentResponse events with WorkflowOutputEvent (#2938) - Change AgentResponseEvent and AgentResponseUpdateEvent to inherit from WorkflowOutputEvent instead of ExecutorEvent - Update AIAgentHostExecutor and HandoffAgentExecutor to use YieldOutputAsync() instead of AddEventAsync() for agent outputs - Add special-casing in InProcessRunnerContext.YieldOutputAsync() to create specific event types for AgentResponse and AgentResponseUpdate, bypassing OutputFilter for backwards compatibility - Update TestRunContext and TestWorkflowContext with same special-casing - Add regression tests in AgentEventsTests --- .../AgentResponseEvent.cs | 4 +- .../AgentResponseUpdateEvent.cs | 4 +- .../InProc/InProcessRunnerContext.cs | 14 +++ .../Specialized/AIAgentHostExecutor.cs | 4 +- .../Specialized/HandoffAgentExecutor.cs | 2 +- .../WorkflowOutputEvent.cs | 12 ++- .../AgentEventsTests.cs | 91 +++++++++++++++++++ .../TestRunContext.cs | 15 ++- .../TestWorkflowContext.cs | 12 +++ 9 files changed, 148 insertions(+), 10 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentEventsTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseEvent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseEvent.cs index a6c0b22525..21e45679eb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseEvent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseEvent.cs @@ -7,14 +7,14 @@ namespace Microsoft.Agents.AI.Workflows; /// /// Represents an event triggered when an agent produces a response. /// -public class AgentResponseEvent : ExecutorEvent +public class AgentResponseEvent : WorkflowOutputEvent { /// /// Initializes a new instance of the class. /// /// The identifier of the executor that generated this event. /// The agent response. - public AgentResponseEvent(string executorId, AgentResponse response) : base(executorId, data: response) + public AgentResponseEvent(string executorId, AgentResponse response) : base(response, executorId) { this.Response = Throw.IfNull(response); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseUpdateEvent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseUpdateEvent.cs index 939e7a67e8..bfe7cfb447 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseUpdateEvent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseUpdateEvent.cs @@ -8,14 +8,14 @@ namespace Microsoft.Agents.AI.Workflows; /// /// Represents an event triggered when an agent run produces an update. /// -public class AgentResponseUpdateEvent : ExecutorEvent +public class AgentResponseUpdateEvent : WorkflowOutputEvent { /// /// Initializes a new instance of the class. /// /// The identifier of the executor that generated this event. /// The agent run response update. - public AgentResponseUpdateEvent(string executorId, AgentResponseUpdate update) : base(executorId, data: update) + public AgentResponseUpdateEvent(string executorId, AgentResponseUpdate update) : base(update, executorId) { this.Update = Throw.IfNull(update); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs index ac0baf157f..e75ae88649 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs @@ -233,6 +233,20 @@ private async ValueTask YieldOutputAsync(string sourceId, object output, Cancell this.CheckEnded(); Throw.IfNull(output); + // Special-case AgentResponse and AgentResponseUpdate to create their specific event types + // and bypass the output filter (for backwards compatibility - these events were previously + // emitted directly via AddEventAsync without filtering) + if (output is AgentResponseUpdate update) + { + await this.AddEventAsync(new AgentResponseUpdateEvent(sourceId, update), cancellationToken).ConfigureAwait(false); + return; + } + else if (output is AgentResponse response) + { + await this.AddEventAsync(new AgentResponseEvent(sourceId, response), cancellationToken).ConfigureAwait(false); + return; + } + Executor sourceExecutor = await this.EnsureExecutorAsync(sourceId, tracer: null, cancellationToken).ConfigureAwait(false); if (!sourceExecutor.CanOutput(output.GetType())) { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs index 97c493d045..d9bd6001cf 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs @@ -181,7 +181,7 @@ await this.EnsureSessionAsync(context, cancellationToken).ConfigureAwait(false), List updates = []; await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false)) { - await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false); + await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false); ExtractUnservicedRequests(update.Contents); updates.Add(update); } @@ -201,7 +201,7 @@ await this.EnsureSessionAsync(context, cancellationToken).ConfigureAwait(false), if (this._options.EmitAgentResponseEvents == true) { - await context.AddEventAsync(new AgentResponseEvent(this.Id, response), cancellationToken).ConfigureAwait(false); + await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false); } if (userInputRequests.Count > 0 || functionCalls.Count > 0) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs index 8c608090f3..1fab6a4984 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs @@ -109,7 +109,7 @@ async Task AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancella updates.Add(update); if (handoffState.TurnToken.EmitEvents is true) { - await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false); + await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false); } } }); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowOutputEvent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowOutputEvent.cs index 8634c89271..f0fe884f6d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowOutputEvent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowOutputEvent.cs @@ -2,15 +2,23 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; namespace Microsoft.Agents.AI.Workflows; /// /// Event triggered when a workflow executor yields output. /// -public sealed class WorkflowOutputEvent : WorkflowEvent +[JsonDerivedType(typeof(AgentResponseEvent))] +[JsonDerivedType(typeof(AgentResponseUpdateEvent))] +public class WorkflowOutputEvent : WorkflowEvent { - internal WorkflowOutputEvent(object data, string executorId) : base(data) + /// + /// Initializes a new instance of the class. + /// + /// The output data. + /// The identifier of the executor that yielded this output. + public WorkflowOutputEvent(object data, string executorId) : base(data) { this.ExecutorId = executorId; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentEventsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentEventsTests.cs new file mode 100644 index 0000000000..3e1ecfe9ba --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentEventsTests.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +public class AgentEventsTests +{ + /// + /// Regression test for https://github.com/microsoft/agent-framework/issues/2938 + /// Verifies that WorkflowOutputEvent is triggered for agent workflows built with + /// WorkflowBuilder directly (without using AgentWorkflowBuilder helpers). + /// + [Fact] + public async Task WorkflowBuilder_WithAgents_EmitsWorkflowOutputEventAsync() + { + // Arrange - Build workflow using WorkflowBuilder directly (not AgentWorkflowBuilder.BuildSequential) + AIAgent agent1 = new TestEchoAgent("agent1"); + AIAgent agent2 = new TestEchoAgent("agent2"); + + Workflow workflow = new WorkflowBuilder(agent1) + .AddEdge(agent1, agent2) + .Build(); + + // Act + await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, new List { new(ChatRole.User, "Hello") }); + await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); + + List outputEvents = new(); + List updateEvents = new(); + + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + if (evt is AgentResponseUpdateEvent updateEvt) + { + updateEvents.Add(updateEvt); + } + + if (evt is WorkflowOutputEvent outputEvt) + { + outputEvents.Add(outputEvt); + } + } + + // Assert - AgentResponseUpdateEvent should now be a WorkflowOutputEvent + Assert.NotEmpty(updateEvents); + Assert.NotEmpty(outputEvents); + // All update events should also be output events (since AgentResponseUpdateEvent now inherits from WorkflowOutputEvent) + Assert.All(updateEvents, updateEvt => Assert.Contains(updateEvt, outputEvents)); + } + + /// + /// Verifies that AgentResponseUpdateEvent inherits from WorkflowOutputEvent. + /// + [Fact] + public void AgentResponseUpdateEvent_IsWorkflowOutputEvent() + { + // Arrange + AgentResponseUpdate update = new(ChatRole.Assistant, "test"); + + // Act + AgentResponseUpdateEvent evt = new("executor1", update); + + // Assert + Assert.IsAssignableFrom(evt); + Assert.Equal("executor1", evt.ExecutorId); + Assert.Same(update, evt.Update); + Assert.Same(update, evt.Data); + } + + /// + /// Verifies that AgentResponseEvent inherits from WorkflowOutputEvent. + /// + [Fact] + public void AgentResponseEvent_IsWorkflowOutputEvent() + { + // Arrange + AgentResponse response = new(new List { new(ChatRole.Assistant, "test") }); + + // Act + AgentResponseEvent evt = new("executor1", response); + + // Assert + Assert.IsAssignableFrom(evt); + Assert.Equal("executor1", evt.ExecutorId); + Assert.Same(response, evt.Response); + Assert.Same(response, evt.Data); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRunContext.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRunContext.cs index 390f99ae05..1a66b59648 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRunContext.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRunContext.cs @@ -50,7 +50,20 @@ public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken ca => runnerContext.AddEventAsync(workflowEvent, cancellationToken); public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default) - => this.AddEventAsync(new WorkflowOutputEvent(output, executorId), cancellationToken); + { + // Special-case AgentResponse and AgentResponseUpdate to create their specific event types + // (consistent with InProcessRunnerContext.YieldOutputAsync) + if (output is AgentResponseUpdate update) + { + return this.AddEventAsync(new AgentResponseUpdateEvent(executorId, update), cancellationToken); + } + else if (output is AgentResponse response) + { + return this.AddEventAsync(new AgentResponseEvent(executorId, response), cancellationToken); + } + + return this.AddEventAsync(new WorkflowOutputEvent(output, executorId), cancellationToken); + } public ValueTask RequestHaltAsync() => this.AddEventAsync(new RequestHaltEvent()); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestWorkflowContext.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestWorkflowContext.cs index 61fb4e1970..d92a96e037 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestWorkflowContext.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestWorkflowContext.cs @@ -41,6 +41,18 @@ public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken ca public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default) { this.YieldedOutputs.Enqueue(output); + + // Special-case AgentResponse and AgentResponseUpdate to create their specific event types + // (consistent with InProcessRunnerContext.YieldOutputAsync) + if (output is AgentResponseUpdate update) + { + return this.AddEventAsync(new AgentResponseUpdateEvent(this._executorId, update), cancellationToken); + } + else if (output is AgentResponse response) + { + return this.AddEventAsync(new AgentResponseEvent(this._executorId, response), cancellationToken); + } + return this.AddEventAsync(new WorkflowOutputEvent(output, this._executorId), cancellationToken); }