Skip to content

Commit a8f2e72

Browse files
committed
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
1 parent fa0cee7 commit a8f2e72

File tree

9 files changed

+148
-10
lines changed

9 files changed

+148
-10
lines changed

dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseEvent.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@ namespace Microsoft.Agents.AI.Workflows;
77
/// <summary>
88
/// Represents an event triggered when an agent produces a response.
99
/// </summary>
10-
public class AgentResponseEvent : ExecutorEvent
10+
public class AgentResponseEvent : WorkflowOutputEvent
1111
{
1212
/// <summary>
1313
/// Initializes a new instance of the <see cref="AgentResponseEvent"/> class.
1414
/// </summary>
1515
/// <param name="executorId">The identifier of the executor that generated this event.</param>
1616
/// <param name="response">The agent response.</param>
17-
public AgentResponseEvent(string executorId, AgentResponse response) : base(executorId, data: response)
17+
public AgentResponseEvent(string executorId, AgentResponse response) : base(response, executorId)
1818
{
1919
this.Response = Throw.IfNull(response);
2020
}

dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseUpdateEvent.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@ namespace Microsoft.Agents.AI.Workflows;
88
/// <summary>
99
/// Represents an event triggered when an agent run produces an update.
1010
/// </summary>
11-
public class AgentResponseUpdateEvent : ExecutorEvent
11+
public class AgentResponseUpdateEvent : WorkflowOutputEvent
1212
{
1313
/// <summary>
1414
/// Initializes a new instance of the <see cref="AgentResponseUpdateEvent"/> class.
1515
/// </summary>
1616
/// <param name="executorId">The identifier of the executor that generated this event.</param>
1717
/// <param name="update">The agent run response update.</param>
18-
public AgentResponseUpdateEvent(string executorId, AgentResponseUpdate update) : base(executorId, data: update)
18+
public AgentResponseUpdateEvent(string executorId, AgentResponseUpdate update) : base(update, executorId)
1919
{
2020
this.Update = Throw.IfNull(update);
2121
}

dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,20 @@ private async ValueTask YieldOutputAsync(string sourceId, object output, Cancell
233233
this.CheckEnded();
234234
Throw.IfNull(output);
235235

236+
// Special-case AgentResponse and AgentResponseUpdate to create their specific event types
237+
// and bypass the output filter (for backwards compatibility - these events were previously
238+
// emitted directly via AddEventAsync without filtering)
239+
if (output is AgentResponseUpdate update)
240+
{
241+
await this.AddEventAsync(new AgentResponseUpdateEvent(sourceId, update), cancellationToken).ConfigureAwait(false);
242+
return;
243+
}
244+
else if (output is AgentResponse response)
245+
{
246+
await this.AddEventAsync(new AgentResponseEvent(sourceId, response), cancellationToken).ConfigureAwait(false);
247+
return;
248+
}
249+
236250
Executor sourceExecutor = await this.EnsureExecutorAsync(sourceId, tracer: null, cancellationToken).ConfigureAwait(false);
237251
if (!sourceExecutor.CanOutput(output.GetType()))
238252
{

dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ await this.EnsureSessionAsync(context, cancellationToken).ConfigureAwait(false),
180180
List<AgentResponseUpdate> updates = [];
181181
await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false))
182182
{
183-
await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false);
183+
await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false);
184184
ExtractUnservicedRequests(update.Contents);
185185
updates.Add(update);
186186
}
@@ -200,7 +200,7 @@ await this.EnsureSessionAsync(context, cancellationToken).ConfigureAwait(false),
200200

201201
if (this._options.EmitAgentResponseEvents == true)
202202
{
203-
await context.AddEventAsync(new AgentResponseEvent(this.Id, response), cancellationToken).ConfigureAwait(false);
203+
await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false);
204204
}
205205

206206
if (userInputRequests.Count > 0 || functionCalls.Count > 0)

dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ async Task AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancella
109109
updates.Add(update);
110110
if (handoffState.TurnToken.EmitEvents is true)
111111
{
112-
await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false);
112+
await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false);
113113
}
114114
}
115115
});

dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowOutputEvent.cs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,23 @@
22

33
using System;
44
using System.Diagnostics.CodeAnalysis;
5+
using System.Text.Json.Serialization;
56

67
namespace Microsoft.Agents.AI.Workflows;
78

89
/// <summary>
910
/// Event triggered when a workflow executor yields output.
1011
/// </summary>
11-
public sealed class WorkflowOutputEvent : WorkflowEvent
12+
[JsonDerivedType(typeof(AgentResponseEvent))]
13+
[JsonDerivedType(typeof(AgentResponseUpdateEvent))]
14+
public class WorkflowOutputEvent : WorkflowEvent
1215
{
13-
internal WorkflowOutputEvent(object data, string executorId) : base(data)
16+
/// <summary>
17+
/// Initializes a new instance of the <see cref="WorkflowOutputEvent"/> class.
18+
/// </summary>
19+
/// <param name="data">The output data.</param>
20+
/// <param name="executorId">The identifier of the executor that yielded this output.</param>
21+
public WorkflowOutputEvent(object data, string executorId) : base(data)
1422
{
1523
this.ExecutorId = executorId;
1624
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System.Collections.Generic;
4+
using System.Threading.Tasks;
5+
using Microsoft.Extensions.AI;
6+
7+
namespace Microsoft.Agents.AI.Workflows.UnitTests;
8+
9+
public class AgentEventsTests
10+
{
11+
/// <summary>
12+
/// Regression test for https://github.com/microsoft/agent-framework/issues/2938
13+
/// Verifies that WorkflowOutputEvent is triggered for agent workflows built with
14+
/// WorkflowBuilder directly (without using AgentWorkflowBuilder helpers).
15+
/// </summary>
16+
[Fact]
17+
public async Task WorkflowBuilder_WithAgents_EmitsWorkflowOutputEventAsync()
18+
{
19+
// Arrange - Build workflow using WorkflowBuilder directly (not AgentWorkflowBuilder.BuildSequential)
20+
AIAgent agent1 = new TestEchoAgent("agent1");
21+
AIAgent agent2 = new TestEchoAgent("agent2");
22+
23+
Workflow workflow = new WorkflowBuilder(agent1)
24+
.AddEdge(agent1, agent2)
25+
.Build();
26+
27+
// Act
28+
await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, new List<ChatMessage> { new(ChatRole.User, "Hello") });
29+
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
30+
31+
List<WorkflowOutputEvent> outputEvents = new();
32+
List<AgentResponseUpdateEvent> updateEvents = new();
33+
34+
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
35+
{
36+
if (evt is AgentResponseUpdateEvent updateEvt)
37+
{
38+
updateEvents.Add(updateEvt);
39+
}
40+
41+
if (evt is WorkflowOutputEvent outputEvt)
42+
{
43+
outputEvents.Add(outputEvt);
44+
}
45+
}
46+
47+
// Assert - AgentResponseUpdateEvent should now be a WorkflowOutputEvent
48+
Assert.NotEmpty(updateEvents);
49+
Assert.NotEmpty(outputEvents);
50+
// All update events should also be output events (since AgentResponseUpdateEvent now inherits from WorkflowOutputEvent)
51+
Assert.All(updateEvents, updateEvt => Assert.Contains(updateEvt, outputEvents));
52+
}
53+
54+
/// <summary>
55+
/// Verifies that AgentResponseUpdateEvent inherits from WorkflowOutputEvent.
56+
/// </summary>
57+
[Fact]
58+
public void AgentResponseUpdateEvent_IsWorkflowOutputEvent()
59+
{
60+
// Arrange
61+
AgentResponseUpdate update = new(ChatRole.Assistant, "test");
62+
63+
// Act
64+
AgentResponseUpdateEvent evt = new("executor1", update);
65+
66+
// Assert
67+
Assert.IsAssignableFrom<WorkflowOutputEvent>(evt);
68+
Assert.Equal("executor1", evt.ExecutorId);
69+
Assert.Same(update, evt.Update);
70+
Assert.Same(update, evt.Data);
71+
}
72+
73+
/// <summary>
74+
/// Verifies that AgentResponseEvent inherits from WorkflowOutputEvent.
75+
/// </summary>
76+
[Fact]
77+
public void AgentResponseEvent_IsWorkflowOutputEvent()
78+
{
79+
// Arrange
80+
AgentResponse response = new(new List<ChatMessage> { new(ChatRole.Assistant, "test") });
81+
82+
// Act
83+
AgentResponseEvent evt = new("executor1", response);
84+
85+
// Assert
86+
Assert.IsAssignableFrom<WorkflowOutputEvent>(evt);
87+
Assert.Equal("executor1", evt.ExecutorId);
88+
Assert.Same(response, evt.Response);
89+
Assert.Same(response, evt.Data);
90+
}
91+
}

dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRunContext.cs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,20 @@ public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken ca
5050
=> runnerContext.AddEventAsync(workflowEvent, cancellationToken);
5151

5252
public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default)
53-
=> this.AddEventAsync(new WorkflowOutputEvent(output, executorId), cancellationToken);
53+
{
54+
// Special-case AgentResponse and AgentResponseUpdate to create their specific event types
55+
// (consistent with InProcessRunnerContext.YieldOutputAsync)
56+
if (output is AgentResponseUpdate update)
57+
{
58+
return this.AddEventAsync(new AgentResponseUpdateEvent(executorId, update), cancellationToken);
59+
}
60+
else if (output is AgentResponse response)
61+
{
62+
return this.AddEventAsync(new AgentResponseEvent(executorId, response), cancellationToken);
63+
}
64+
65+
return this.AddEventAsync(new WorkflowOutputEvent(output, executorId), cancellationToken);
66+
}
5467

5568
public ValueTask RequestHaltAsync()
5669
=> this.AddEventAsync(new RequestHaltEvent());

dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestWorkflowContext.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,18 @@ public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken ca
4141
public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default)
4242
{
4343
this.YieldedOutputs.Enqueue(output);
44+
45+
// Special-case AgentResponse and AgentResponseUpdate to create their specific event types
46+
// (consistent with InProcessRunnerContext.YieldOutputAsync)
47+
if (output is AgentResponseUpdate update)
48+
{
49+
return this.AddEventAsync(new AgentResponseUpdateEvent(this._executorId, update), cancellationToken);
50+
}
51+
else if (output is AgentResponse response)
52+
{
53+
return this.AddEventAsync(new AgentResponseEvent(this._executorId, response), cancellationToken);
54+
}
55+
4456
return this.AddEventAsync(new WorkflowOutputEvent(output, this._executorId), cancellationToken);
4557
}
4658

0 commit comments

Comments
 (0)