diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs
index bd0b3114f1..ccb993b188 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs
@@ -13,10 +13,18 @@ namespace Microsoft.Agents.AI.Workflows;
///
public sealed class HandoffsWorkflowBuilder
{
- internal const string FunctionPrefix = "handoff_to_";
+ ///
+ /// The prefix for function calls that trigger handoffs to other agents; the full name is then `{FunctionPrefix}<agent_id>`,
+ /// where `<agent_id>` is the ID of the target agent to hand off to.
+ ///
+ public const string FunctionPrefix = "handoff_to_";
+
private readonly AIAgent _initialAgent;
private readonly Dictionary> _targets = [];
private readonly HashSet _allAgents = new(AIAgentIDEqualityComparer.Instance);
+
+ private bool _emitAgentResponseEvents;
+ private bool _emitAgentResponseUpdateEvents;
private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly;
///
@@ -47,9 +55,13 @@ public sealed class HandoffsWorkflowBuilder
""";
///
- /// Sets additional instructions to provide to an agent that has handoffs about how and when to
- /// perform them.
+ /// Sets instructions to provide to each agent that has handoffs about how and when to perform them.
///
+ ///
+ /// In the vast majority of cases, the will be sufficient, and there will be no need to customize.
+ /// If you do provide alternate instructions, remember to explain the mechanics of the handoff function tool call, using see
+ /// constant.
+ ///
/// The instructions to provide, or to restore the default instructions.
public HandoffsWorkflowBuilder WithHandoffInstructions(string? instructions)
{
@@ -57,6 +69,29 @@ public sealed class HandoffsWorkflowBuilder
return this;
}
+ ///
+ /// Sets a value indicating whether agent streaming update events should be emitted during execution.
+ /// If , the value will be taken from the
+ ///
+ ///
+ ///
+ public HandoffsWorkflowBuilder EmitAgentResponseUpdateEvents(bool emitAgentResponseUpdateEvents = true)
+ {
+ this._emitAgentResponseUpdateEvents = emitAgentResponseUpdateEvents;
+ return this;
+ }
+
+ ///
+ /// Sets a value indicating whether aggregated agent response events should be emitted during execution.
+ ///
+ ///
+ ///
+ public HandoffsWorkflowBuilder EmitAgentResponseEvents(bool emitAgentResponseEvents = true)
+ {
+ this._emitAgentResponseEvents = emitAgentResponseEvents;
+ return this;
+ }
+
///
/// Sets the behavior for filtering and contents from
/// s flowing through the handoff workflow. Defaults to .
@@ -175,7 +210,10 @@ public sealed class HandoffsWorkflowBuilder
HandoffsEndExecutor end = new();
WorkflowBuilder builder = new(start);
- HandoffAgentExecutorOptions options = new(this.HandoffInstructions, this._toolCallFilteringBehavior);
+ HandoffAgentExecutorOptions options = new(this.HandoffInstructions,
+ this._emitAgentResponseEvents,
+ this._emitAgentResponseUpdateEvents,
+ this._toolCallFilteringBehavior);
// Create an AgentExecutor for each again.
Dictionary executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, options));
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs
index cf9ddbe3a3..3f3d83fbee 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs
@@ -12,6 +12,15 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
internal record AIAgentHostState(JsonElement? ThreadState, bool? CurrentTurnEmitEvents);
+internal static class TurnExtensions
+{
+ public static bool ShouldEmitStreamingEvents(this TurnToken token, bool? agentSetting)
+ => token.EmitEvents ?? agentSetting ?? false;
+
+ public static bool ShouldEmitStreamingEvents(bool? turnTokenSetting, bool? agentSetting)
+ => turnTokenSetting ?? agentSetting ?? false;
+}
+
internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
{
private readonly AIAgent _agent;
@@ -104,9 +113,6 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
}, context, cancellationToken);
}
- public bool ShouldEmitStreamingEvents(bool? emitEvents)
- => emitEvents ?? this._options.EmitAgentUpdateEvents ?? false;
-
private async ValueTask EnsureSessionAsync(IWorkflowContext context, CancellationToken cancellationToken) =>
this._session ??= await this._agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
@@ -175,7 +181,10 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
}
protected override ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
- => this.ContinueTurnAsync(messages, context, this.ShouldEmitStreamingEvents(emitEvents), cancellationToken);
+ => this.ContinueTurnAsync(messages,
+ context,
+ TurnExtensions.ShouldEmitStreamingEvents(turnTokenSetting: emitEvents, this._options.EmitAgentUpdateEvents),
+ cancellationToken);
private async ValueTask InvokeAgentAsync(IEnumerable messages, IWorkflowContext context, bool emitEvents, CancellationToken cancellationToken = default)
{
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs
index 9883358e9e..21519e07ee 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs
@@ -14,14 +14,20 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
internal sealed class HandoffAgentExecutorOptions
{
- public HandoffAgentExecutorOptions(string? handoffInstructions, HandoffToolCallFilteringBehavior toolCallFilteringBehavior)
+ public HandoffAgentExecutorOptions(string? handoffInstructions, bool emitAgentResponseEvents, bool? emitAgentResponseUpdateEvents, HandoffToolCallFilteringBehavior toolCallFilteringBehavior)
{
this.HandoffInstructions = handoffInstructions;
+ this.EmitAgentResponseEvents = emitAgentResponseEvents;
+ this.EmitAgentResponseUpdateEvents = emitAgentResponseUpdateEvents;
this.ToolCallFilteringBehavior = toolCallFilteringBehavior;
}
public string? HandoffInstructions { get; set; }
+ public bool EmitAgentResponseEvents { get; set; }
+
+ public bool? EmitAgentResponseUpdateEvents { get; set; }
+
public HandoffToolCallFilteringBehavior ToolCallFilteringBehavior { get; set; } = HandoffToolCallFilteringBehavior.HandoffOnly;
}
@@ -252,11 +258,7 @@ internal sealed class HandoffAgentExecutor(
AgentResponse agentResponse = updates.ToAgentResponse();
- // Since there is no good way to configure the agent output behaviour due to how we add it to Handoff orchestration
- // configurations, treat the emitEvents flag as simply determining whether to stream updates or to emit the whole response
- // It would make little sense to avoid emitting any agent responses since this is only used in Orchestration workflows,
- // which are Agent-only, and thus would do nothing.
- if (message.TurnToken.EmitEvents is not true)
+ if (options.EmitAgentResponseEvents)
{
await context.YieldOutputAsync(agentResponse, cancellationToken).ConfigureAwait(false);
}
@@ -270,7 +272,7 @@ internal sealed class HandoffAgentExecutor(
async Task AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellationToken)
{
updates.Add(update);
- if (message.TurnToken.EmitEvents is true)
+ if (message.TurnToken.ShouldEmitStreamingEvents(options.EmitAgentResponseUpdateEvents))
{
await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false);
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs
index f6d48062b3..9cd9eb45e6 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs
@@ -10,106 +10,6 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
-public abstract class AIAgentHostingExecutorTestsBase
-{
- protected const string TestAgentId = nameof(TestAgentId);
- protected const string TestAgentName = nameof(TestAgentName);
-
- private static readonly string[] s_messageStrings = [
- "",
- "Hello world!",
- "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
- "Quisque dignissim ante odio, at facilisis orci porta a. Duis mi augue, fringilla eu egestas a, pellentesque sed lacus."
- ];
-
- protected static List TestMessages => TestReplayAgent.ToChatMessages(s_messageStrings);
-
- protected static void CheckResponseUpdateEventsAgainstTestMessages(AgentResponseUpdateEvent[] updates, bool expectingEvents, string expectedExecutorId)
- {
- if (expectingEvents)
- {
- // The way TestReplayAgent is set up, it will emit one update per non-empty AIContent
- List expectedUpdateContents = TestMessages.SelectMany(message => message.Contents).ToList();
-
- updates.Should().HaveCount(expectedUpdateContents.Count);
- for (int i = 0; i < updates.Length; i++)
- {
- AgentResponseUpdateEvent updateEvent = updates[i];
- AIContent expectedUpdateContent = expectedUpdateContents[i];
-
- updateEvent.ExecutorId.Should().Be(expectedExecutorId);
-
- AgentResponseUpdate update = updateEvent.Update;
- update.AuthorName.Should().Be(TestAgentName);
- update.AgentId.Should().Be(TestAgentId);
- update.Contents.Should().HaveCount(1);
- update.Contents[0].Should().BeEquivalentTo(expectedUpdateContent);
- }
- }
- else
- {
- updates.Should().BeEmpty();
- }
- }
-
- protected static void CheckResponseEventsAgainstTestMessages(AgentResponseEvent[] updates, bool expectingResponse, string expectedExecutorId)
- {
- if (expectingResponse)
- {
- updates.Should().HaveCount(1);
-
- AgentResponseEvent responseEvent = updates[0];
- responseEvent.ExecutorId.Should().Be(expectedExecutorId);
-
- AgentResponse response = responseEvent.Response;
- response.AgentId.Should().Be(TestAgentId);
- response.Messages.Should().HaveCount(TestMessages.Count - 1);
-
- for (int i = 0; i < response.Messages.Count; i++)
- {
- ChatMessage responseMessage = response.Messages[i];
- ChatMessage expectedMessage = TestMessages[i + 1]; // Skip the first empty message
-
- responseMessage.AuthorName.Should().Be(TestAgentName);
- responseMessage.Text.Should().Be(expectedMessage.Text);
- }
- }
- else
- {
- updates.Should().BeEmpty();
- }
- }
-}
-
-public class HandoffAgentExecutorTests : AIAgentHostingExecutorTestsBase
-{
- [Theory]
- [InlineData(null)]
- [InlineData(true)]
- [InlineData(false)]
- public async Task Test_HandoffAgentExecutor_EmitsCorrectOutputTypeAsync(bool? turnSetting)
- {
- // Arrange
- TestRunContext testContext = new();
- TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName);
- HandoffAgentExecutor executor = new(agent, new("", HandoffToolCallFilteringBehavior.None));
- testContext.ConfigureExecutor(executor);
-
- // Act
- HandoffState message = new(new(turnSetting), null, []);
- await executor.HandleAsync(message, testContext.BindWorkflowContext(executor.Id));
-
- // Assert
- bool expectingStreamingUpdates = turnSetting is true;
-
- AgentResponseEvent[] responses = testContext.Events.OfType().ToArray();
- CheckResponseEventsAgainstTestMessages(responses, !expectingStreamingUpdates, agent.GetDescriptiveId());
-
- AgentResponseUpdateEvent[] updates = testContext.Events.OfType().ToArray();
- CheckResponseUpdateEventsAgainstTestMessages(updates, expectingStreamingUpdates, agent.GetDescriptiveId());
- }
-}
-
public class AIAgentHostExecutorTests : AIAgentHostingExecutorTestsBase
{
[Theory]
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostingExecutorTestsBase.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostingExecutorTestsBase.cs
new file mode 100644
index 0000000000..2285074ce3
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostingExecutorTestsBase.cs
@@ -0,0 +1,79 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Linq;
+using FluentAssertions;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Workflows.UnitTests;
+
+public abstract class AIAgentHostingExecutorTestsBase
+{
+ protected const string TestAgentId = nameof(TestAgentId);
+ protected const string TestAgentName = nameof(TestAgentName);
+
+ private static readonly string[] s_messageStrings = [
+ "",
+ "Hello world!",
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
+ "Quisque dignissim ante odio, at facilisis orci porta a. Duis mi augue, fringilla eu egestas a, pellentesque sed lacus."
+ ];
+
+ protected static List TestMessages => TestReplayAgent.ToChatMessages(s_messageStrings);
+
+ protected static void CheckResponseUpdateEventsAgainstTestMessages(AgentResponseUpdateEvent[] updates, bool expectingEvents, string expectedExecutorId)
+ {
+ if (expectingEvents)
+ {
+ // The way TestReplayAgent is set up, it will emit one update per non-empty AIContent
+ List expectedUpdateContents = TestMessages.SelectMany(message => message.Contents).ToList();
+
+ updates.Should().HaveCount(expectedUpdateContents.Count);
+ for (int i = 0; i < updates.Length; i++)
+ {
+ AgentResponseUpdateEvent updateEvent = updates[i];
+ AIContent expectedUpdateContent = expectedUpdateContents[i];
+
+ updateEvent.ExecutorId.Should().Be(expectedExecutorId);
+
+ AgentResponseUpdate update = updateEvent.Update;
+ update.AuthorName.Should().Be(TestAgentName);
+ update.AgentId.Should().Be(TestAgentId);
+ update.Contents.Should().HaveCount(1);
+ update.Contents[0].Should().BeEquivalentTo(expectedUpdateContent);
+ }
+ }
+ else
+ {
+ updates.Should().BeEmpty();
+ }
+ }
+
+ protected static void CheckResponseEventsAgainstTestMessages(AgentResponseEvent[] updates, bool expectingResponse, string expectedExecutorId)
+ {
+ if (expectingResponse)
+ {
+ updates.Should().HaveCount(1);
+
+ AgentResponseEvent responseEvent = updates[0];
+ responseEvent.ExecutorId.Should().Be(expectedExecutorId);
+
+ AgentResponse response = responseEvent.Response;
+ response.AgentId.Should().Be(TestAgentId);
+ response.Messages.Should().HaveCount(TestMessages.Count - 1);
+
+ for (int i = 0; i < response.Messages.Count; i++)
+ {
+ ChatMessage responseMessage = response.Messages[i];
+ ChatMessage expectedMessage = TestMessages[i + 1]; // Skip the first empty message
+
+ responseMessage.AuthorName.Should().Be(TestAgentName);
+ responseMessage.Text.Should().Be(expectedMessage.Text);
+ }
+ }
+ else
+ {
+ updates.Should().BeEmpty();
+ }
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs
new file mode 100644
index 0000000000..8bdbe23c5f
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs
@@ -0,0 +1,71 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Workflows.Specialized;
+
+namespace Microsoft.Agents.AI.Workflows.UnitTests;
+
+public class HandoffAgentExecutorTests : AIAgentHostingExecutorTestsBase
+{
+ [Theory]
+ [InlineData(null, null)]
+ [InlineData(null, true)]
+ [InlineData(null, false)]
+ [InlineData(true, null)]
+ [InlineData(true, true)]
+ [InlineData(true, false)]
+ [InlineData(false, null)]
+ [InlineData(false, true)]
+ [InlineData(false, false)]
+ public async Task Test_HandoffAgentExecutor_EmitsStreamingUpdatesIFFConfiguredAsync(bool? executorSetting, bool? turnSetting)
+ {
+ // Arrange
+ TestRunContext testContext = new();
+ TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName);
+
+ HandoffAgentExecutorOptions options = new("",
+ emitAgentResponseEvents: false,
+ emitAgentResponseUpdateEvents: executorSetting,
+ HandoffToolCallFilteringBehavior.None);
+
+ HandoffAgentExecutor executor = new(agent, options);
+ testContext.ConfigureExecutor(executor);
+
+ // Act
+ HandoffState message = new(new(turnSetting), null, []);
+ await executor.HandleAsync(message, testContext.BindWorkflowContext(executor.Id));
+
+ // Assert
+ bool expectingStreamingUpdates = turnSetting ?? executorSetting ?? false;
+
+ AgentResponseUpdateEvent[] updates = testContext.Events.OfType().ToArray();
+ CheckResponseUpdateEventsAgainstTestMessages(updates, expectingStreamingUpdates, agent.GetDescriptiveId());
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task Test_HandoffAgentExecutor_EmitsResponseIFFConfiguredAsync(bool executorSetting)
+ {
+ // Arrange
+ TestRunContext testContext = new();
+ TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName);
+
+ HandoffAgentExecutorOptions options = new("",
+ emitAgentResponseEvents: executorSetting,
+ emitAgentResponseUpdateEvents: false,
+ HandoffToolCallFilteringBehavior.None);
+
+ HandoffAgentExecutor executor = new(agent, options);
+ testContext.ConfigureExecutor(executor);
+
+ // Act
+ HandoffState message = new(new(false), null, []);
+ await executor.HandleAsync(message, testContext.BindWorkflowContext(executor.Id));
+
+ // Assert
+ AgentResponseEvent[] updates = testContext.Events.OfType().ToArray();
+ CheckResponseEventsAgainstTestMessages(updates, expectingResponse: executorSetting, agent.GetDescriptiveId());
+ }
+}