feat: Add output configurability to Handoffs

This commit is contained in:
Jacob Alber
2026-03-25 16:58:51 -04:00
parent d9cfe4f78f
commit 8396db53ca
6 changed files with 214 additions and 115 deletions
@@ -13,10 +13,18 @@ namespace Microsoft.Agents.AI.Workflows;
/// </summary>
public sealed class HandoffsWorkflowBuilder
{
internal const string FunctionPrefix = "handoff_to_";
/// <summary>
/// The prefix for function calls that trigger handoffs to other agents; the full name is then `{FunctionPrefix}&lt;agent_id&gt;`,
/// where `&lt;agent_id&gt;` is the ID of the target agent to hand off to.
/// </summary>
public const string FunctionPrefix = "handoff_to_";
private readonly AIAgent _initialAgent;
private readonly Dictionary<AIAgent, HashSet<HandoffTarget>> _targets = [];
private readonly HashSet<AIAgent> _allAgents = new(AIAgentIDEqualityComparer.Instance);
private bool _emitAgentResponseEvents;
private bool _emitAgentResponseUpdateEvents;
private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly;
/// <summary>
@@ -47,9 +55,13 @@ public sealed class HandoffsWorkflowBuilder
""";
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// In the vast majority of cases, the <see cref="DefaultHandoffInstructions"/> 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
/// <see cref="FunctionPrefix"/> constant.
/// </remarks>
/// <param name="instructions">The instructions to provide, or <see langword="null"/> to restore the default instructions.</param>
public HandoffsWorkflowBuilder WithHandoffInstructions(string? instructions)
{
@@ -57,6 +69,29 @@ public sealed class HandoffsWorkflowBuilder
return this;
}
/// <summary>
/// Sets a value indicating whether agent streaming update events should be emitted during execution.
/// If <see langword="null"/>, the value will be taken from the <see cref="TurnToken"/>
/// </summary>
/// <param name="emitAgentResponseUpdateEvents"></param>
/// <returns></returns>
public HandoffsWorkflowBuilder EmitAgentResponseUpdateEvents(bool emitAgentResponseUpdateEvents = true)
{
this._emitAgentResponseUpdateEvents = emitAgentResponseUpdateEvents;
return this;
}
/// <summary>
/// Sets a value indicating whether aggregated agent response events should be emitted during execution.
/// </summary>
/// <param name="emitAgentResponseEvents"></param>
/// <returns></returns>
public HandoffsWorkflowBuilder EmitAgentResponseEvents(bool emitAgentResponseEvents = true)
{
this._emitAgentResponseEvents = emitAgentResponseEvents;
return this;
}
/// <summary>
/// Sets the behavior for filtering <see cref="FunctionCallContent"/> and <see cref="ChatRole.Tool"/> contents from
/// <see cref="ChatMessage"/>s flowing through the handoff workflow. Defaults to <see cref="HandoffToolCallFilteringBehavior.HandoffOnly"/>.
@@ -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<string, HandoffAgentExecutor> executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, options));
@@ -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<AgentSession> 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<ChatMessage> 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<AgentResponse> InvokeAgentAsync(IEnumerable<ChatMessage> messages, IWorkflowContext context, bool emitEvents, CancellationToken cancellationToken = default)
{
@@ -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);
}
@@ -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<ChatMessage> 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<AIContent> 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<AgentResponseEvent>().ToArray();
CheckResponseEventsAgainstTestMessages(responses, !expectingStreamingUpdates, agent.GetDescriptiveId());
AgentResponseUpdateEvent[] updates = testContext.Events.OfType<AgentResponseUpdateEvent>().ToArray();
CheckResponseUpdateEventsAgainstTestMessages(updates, expectingStreamingUpdates, agent.GetDescriptiveId());
}
}
public class AIAgentHostExecutorTests : AIAgentHostingExecutorTestsBase
{
[Theory]
@@ -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<ChatMessage> 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<AIContent> 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();
}
}
}
@@ -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<AgentResponseUpdateEvent>().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<AgentResponseEvent>().ToArray();
CheckResponseEventsAgainstTestMessages(updates, expectingResponse: executorSetting, agent.GetDescriptiveId());
}
}