feat: Implement return-to-previous routing in handoff workflow

- Also obsoletes HandoffsWorkflowBuilder => HandoffWorkflowBuilder (no "s")
This commit is contained in:
Jacob Alber
2026-02-27 18:48:19 +00:00
Unverified
parent 35f44e854e
commit 0079a92324
8 changed files with 291 additions and 30 deletions
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Agents.AI.Workflows.Specialized;
@@ -8,10 +9,21 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <inheritdoc/>
[Obsolete("Prefer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed in a future release before GA.")]
public sealed class HandoffsWorkflowBuilder(AIAgent initialAgent) : HandoffWorkflowBuilderCore<HandoffsWorkflowBuilder>(initialAgent)
{
}
/// <inheritdoc/>
public sealed class HandoffWorkflowBuilder(AIAgent initialAgent) : HandoffWorkflowBuilderCore<HandoffWorkflowBuilder>(initialAgent)
{
}
/// <summary>
/// Provides a builder for specifying the handoff relationships between agents and building the resulting workflow.
/// </summary>
public sealed class HandoffsWorkflowBuilder
public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkflowBuilderCore<TBuilder>
{
/// <summary>
/// The prefix for function calls that trigger handoffs to other agents; the full name is then `{FunctionPrefix}&lt;agent_id&gt;`,
@@ -26,12 +38,13 @@ public sealed class HandoffsWorkflowBuilder
private bool _emitAgentResponseEvents;
private bool _emitAgentResponseUpdateEvents;
private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly;
private bool _returnToPrevious;
/// <summary>
/// Initializes a new instance of the <see cref="HandoffsWorkflowBuilder"/> class with no handoff relationships.
/// </summary>
/// <param name="initialAgent">The first agent to be invoked (prior to any handoff).</param>
internal HandoffsWorkflowBuilder(AIAgent initialAgent)
internal HandoffWorkflowBuilderCore(AIAgent initialAgent)
{
this._initialAgent = initialAgent;
this._allAgents.Add(initialAgent);
@@ -63,10 +76,10 @@ public sealed class HandoffsWorkflowBuilder
/// <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)
public TBuilder WithHandoffInstructions(string? instructions)
{
this.HandoffInstructions = instructions ?? DefaultHandoffInstructions;
return this;
return (TBuilder)this;
}
/// <summary>
@@ -75,10 +88,10 @@ public sealed class HandoffsWorkflowBuilder
/// </summary>
/// <param name="emitAgentResponseUpdateEvents"></param>
/// <returns></returns>
public HandoffsWorkflowBuilder EmitAgentResponseUpdateEvents(bool emitAgentResponseUpdateEvents = true)
public TBuilder EmitAgentResponseUpdateEvents(bool emitAgentResponseUpdateEvents = true)
{
this._emitAgentResponseUpdateEvents = emitAgentResponseUpdateEvents;
return this;
return (TBuilder)this;
}
/// <summary>
@@ -86,10 +99,10 @@ public sealed class HandoffsWorkflowBuilder
/// </summary>
/// <param name="emitAgentResponseEvents"></param>
/// <returns></returns>
public HandoffsWorkflowBuilder EmitAgentResponseEvents(bool emitAgentResponseEvents = true)
public TBuilder EmitAgentResponseEvents(bool emitAgentResponseEvents = true)
{
this._emitAgentResponseEvents = emitAgentResponseEvents;
return this;
return (TBuilder)this;
}
/// <summary>
@@ -97,10 +110,21 @@ public sealed class HandoffsWorkflowBuilder
/// <see cref="ChatMessage"/>s flowing through the handoff workflow. Defaults to <see cref="HandoffToolCallFilteringBehavior.HandoffOnly"/>.
/// </summary>
/// <param name="behavior">The filtering behavior to apply.</param>
public HandoffsWorkflowBuilder WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior behavior)
public TBuilder WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior behavior)
{
this._toolCallFilteringBehavior = behavior;
return this;
return (TBuilder)this;
}
/// <summary>
/// Configures the workflow so that subsequent user turns route directly back to the specialist agent
/// that handled the previous turn, rather than always routing through the initial (coordinator) agent.
/// </summary>
/// <returns>The updated <see cref="HandoffsWorkflowBuilder"/> instance.</returns>
public TBuilder EnableReturnToPrevious()
{
this._returnToPrevious = true;
return (TBuilder)this;
}
/// <summary>
@@ -110,7 +134,7 @@ public sealed class HandoffsWorkflowBuilder
/// <param name="to">The target agents to add as handoff targets for the source agent.</param>
/// <returns>The updated <see cref="HandoffsWorkflowBuilder"/> instance.</returns>
/// <remarks>The handoff reason for each target in <paramref name="to"/> is derived from that agent's description or name.</remarks>
public HandoffsWorkflowBuilder WithHandoffs(AIAgent from, IEnumerable<AIAgent> to)
public TBuilder WithHandoffs(AIAgent from, IEnumerable<AIAgent> to)
{
Throw.IfNull(from);
Throw.IfNull(to);
@@ -125,7 +149,7 @@ public sealed class HandoffsWorkflowBuilder
this.WithHandoff(from, target);
}
return this;
return (TBuilder)this;
}
/// <summary>
@@ -138,7 +162,7 @@ public sealed class HandoffsWorkflowBuilder
/// If <see langword="null"/>, the reason is derived from <paramref name="to"/>'s description or name.
/// </param>
/// <returns>The updated <see cref="HandoffsWorkflowBuilder"/> instance.</returns>
public HandoffsWorkflowBuilder WithHandoffs(IEnumerable<AIAgent> from, AIAgent to, string? handoffReason = null)
public TBuilder WithHandoffs(IEnumerable<AIAgent> from, AIAgent to, string? handoffReason = null)
{
Throw.IfNull(from);
Throw.IfNull(to);
@@ -153,7 +177,7 @@ public sealed class HandoffsWorkflowBuilder
this.WithHandoff(source, to, handoffReason);
}
return this;
return (TBuilder)this;
}
/// <summary>
@@ -166,7 +190,7 @@ public sealed class HandoffsWorkflowBuilder
/// If <see langword="null"/>, the reason is derived from <paramref name="to"/>'s description or name.
/// </param>
/// <returns>The updated <see cref="HandoffsWorkflowBuilder"/> instance.</returns>
public HandoffsWorkflowBuilder WithHandoff(AIAgent from, AIAgent to, string? handoffReason = null)
public TBuilder WithHandoff(AIAgent from, AIAgent to, string? handoffReason = null)
{
Throw.IfNull(from);
Throw.IfNull(to);
@@ -196,7 +220,7 @@ public sealed class HandoffsWorkflowBuilder
Throw.InvalidOperationException($"A handoff from agent '{from.Name ?? from.Id}' to agent '{to.Name ?? to.Id}' has already been registered.");
}
return this;
return (TBuilder)this;
}
/// <summary>
@@ -206,8 +230,8 @@ public sealed class HandoffsWorkflowBuilder
/// <returns>The workflow built based on the handoffs in the builder.</returns>
public Workflow Build()
{
HandoffsStartExecutor start = new();
HandoffsEndExecutor end = new();
HandoffsStartExecutor start = new(this._returnToPrevious);
HandoffsEndExecutor end = new(this._returnToPrevious);
WorkflowBuilder builder = new(start);
HandoffAgentExecutorOptions options = new(this.HandoffInstructions,
@@ -215,11 +239,31 @@ public sealed class HandoffsWorkflowBuilder
this._emitAgentResponseUpdateEvents,
this._toolCallFilteringBehavior);
// Create an AgentExecutor for each again.
// Create an AgentExecutor for each agent.
Dictionary<string, HandoffAgentExecutor> executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, options));
// Connect the start executor to the initial agent.
builder.AddEdge(start, executors[this._initialAgent.Id]);
// Connect the start executor to the initial agent (or use dynamic routing when ReturnToPrevious is enabled).
if (this._returnToPrevious)
{
string initialAgentId = this._initialAgent.Id;
builder.AddSwitch(start, sb =>
{
foreach (var agent in this._allAgents)
{
if (agent.Id != initialAgentId)
{
string agentId = agent.Id;
sb.AddCase<HandoffState>(state => state?.CurrentAgentId == agentId, executors[agentId]);
}
}
sb.WithDefault(executors[initialAgentId]);
});
}
else
{
builder.AddEdge(start, executors[this._initialAgent.Id]);
}
// Initialize each executor with its handoff targets to the other executors.
foreach (var agent in this._allAgents)
@@ -42,7 +42,7 @@ internal sealed class HandoffMessagesFilter
internal static bool IsHandoffFunctionName(string name)
{
return name.StartsWith(HandoffsWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal);
return name.StartsWith(HandoffWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal);
}
public IEnumerable<ChatMessage> FilterMessages(List<ChatMessage> messages)
@@ -173,6 +173,7 @@ internal sealed class HandoffAgentExecutor(
private readonly AIAgent _agent = agent;
private readonly HashSet<string> _handoffFunctionNames = [];
private readonly Dictionary<string, string> _handoffFunctionToAgentId = [];
private ChatClientAgentRunOptions? _agentOptions;
public void Initialize(
@@ -199,9 +200,10 @@ internal sealed class HandoffAgentExecutor(
foreach (HandoffTarget handoff in handoffs)
{
index++;
var handoffFunc = AIFunctionFactory.CreateDeclaration($"{HandoffsWorkflowBuilder.FunctionPrefix}{index}", handoff.Reason, s_handoffSchema);
var handoffFunc = AIFunctionFactory.CreateDeclaration($"{HandoffWorkflowBuilder.FunctionPrefix}{index}", handoff.Reason, s_handoffSchema);
this._handoffFunctionNames.Add(handoffFunc.Name);
this._handoffFunctionToAgentId[handoffFunc.Name] = handoff.Target.Id;
this._agentOptions.ChatOptions.Tools.Add(handoffFunc);
@@ -267,7 +269,11 @@ internal sealed class HandoffAgentExecutor(
roleChanges.ResetUserToAssistantForChangedRoles();
return new(message.TurnToken, requestedHandoff, allMessages);
string currentAgentId = requestedHandoff is not null && this._handoffFunctionToAgentId.TryGetValue(requestedHandoff, out string? targetAgentId)
? targetAgentId
: this._agent.Id;
return new(message.TurnToken, requestedHandoff, allMessages, currentAgentId);
async Task AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellationToken)
{
@@ -8,4 +8,5 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
internal sealed record class HandoffState(
TurnToken TurnToken,
string? InvokedHandoff,
List<ChatMessage> Messages);
List<ChatMessage> Messages,
string? CurrentAgentId = null);
@@ -0,0 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Workflows.Specialized;
/// <summary>Tracks the current agent ID across turns when return-to-previous routing is enabled.</summary>
internal sealed class HandoffsCurrentAgentTracker
{
public string? CurrentAgentId { get; set; }
}
@@ -1,20 +1,31 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
/// <summary>Executor used at the end of a handoff workflow to raise a final completed event.</summary>
internal sealed class HandoffsEndExecutor() : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor
internal sealed class HandoffsEndExecutor(HandoffsCurrentAgentTracker? tracker = null) : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor
{
public const string ExecutorId = "HandoffEnd";
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) =>
protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<HandoffState>((handoff, context, cancellationToken) =>
context.YieldOutputAsync(handoff.Messages, cancellationToken)))
this.HandleAsync(handoff, context, cancellationToken)))
.YieldsOutput<List<ChatMessage>>();
private async ValueTask HandleAsync(HandoffState handoff, IWorkflowContext context, CancellationToken cancellationToken)
{
if (tracker is not null && handoff.CurrentAgentId is not null)
{
tracker.CurrentAgentId = handoff.CurrentAgentId;
}
await context.YieldOutputAsync(handoff.Messages, cancellationToken).ConfigureAwait(false);
}
public ValueTask ResetAsync() => default;
}
@@ -8,7 +8,7 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
/// <summary>Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token.</summary>
internal sealed class HandoffsStartExecutor() : ChatProtocolExecutor(ExecutorId, DefaultOptions, declareCrossRunShareable: true), IResettableExecutor
internal sealed class HandoffsStartExecutor(HandoffsCurrentAgentTracker? tracker = null) : ChatProtocolExecutor(ExecutorId, DefaultOptions, declareCrossRunShareable: true), IResettableExecutor
{
internal const string ExecutorId = "HandoffStart";
@@ -22,7 +22,7 @@ internal sealed class HandoffsStartExecutor() : ChatProtocolExecutor(ExecutorId,
base.ConfigureProtocol(protocolBuilder).SendsMessage<HandoffState>();
protected override ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
=> context.SendMessageAsync(new HandoffState(new(emitEvents), null, messages), cancellationToken: cancellationToken);
=> context.SendMessageAsync(new HandoffState(new(emitEvents), null, messages, tracker?.CurrentAgentId), cancellationToken: cancellationToken);
public new ValueTask ResetAsync() => base.ResetAsync();
}
@@ -680,6 +680,196 @@ public class AgentWorkflowBuilderTests
}
}
[Fact]
public async Task Handoffs_ReturnToPrevious_DisabledByDefault_SecondTurnRoutesViaCoordinatorAsync()
{
int coordinatorCallCount = 0;
var coordinator = new ChatClientAgent(new MockChatClient((messages, options) =>
{
coordinatorCallCount++;
if (coordinatorCallCount == 1)
{
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
Assert.NotNull(transferFuncName);
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)]));
}
return new(new ChatMessage(ChatRole.Assistant, "coordinator responded on turn 2"));
}), name: "coordinator");
var specialist = new ChatClientAgent(new MockChatClient((messages, options) =>
new(new ChatMessage(ChatRole.Assistant, "specialist responded"))),
name: "specialist", description: "The specialist agent");
var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator)
.WithHandoff(coordinator, specialist)
.Build();
var environment = InProcessExecution.Lockstep;
string sessionId = Guid.NewGuid().ToString("N");
// Turn 1: coordinator hands off to specialist
(_, List<ChatMessage>? turn1Result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], environment, sessionId);
Assert.Equal(1, coordinatorCallCount);
// Turn 2: without ReturnToPrevious, coordinator should be invoked again
Assert.NotNull(turn1Result);
turn1Result.Add(new ChatMessage(ChatRole.User, "my id is 12345"));
_ = await RunWorkflowAsync(workflow, turn1Result, environment, sessionId);
Assert.Equal(2, coordinatorCallCount);
}
[Fact]
public async Task Handoffs_ReturnToPrevious_Enabled_SecondTurnRoutesDirectlyToSpecialistAsync()
{
int coordinatorCallCount = 0;
int specialistCallCount = 0;
var coordinator = new ChatClientAgent(new MockChatClient((messages, options) =>
{
coordinatorCallCount++;
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
Assert.NotNull(transferFuncName);
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)]));
}), name: "coordinator");
var specialist = new ChatClientAgent(new MockChatClient((messages, options) =>
{
specialistCallCount++;
return new(new ChatMessage(ChatRole.Assistant, "specialist responded"));
}), name: "specialist", description: "The specialist agent");
var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator)
.WithHandoff(coordinator, specialist)
.EnableReturnToPrevious()
.Build();
var environment = InProcessExecution.Lockstep;
string sessionId = Guid.NewGuid().ToString("N");
// Turn 1: coordinator hands off to specialist
(_, List<ChatMessage>? turn1Result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], environment, sessionId);
Assert.Equal(1, coordinatorCallCount);
Assert.Equal(1, specialistCallCount);
// Turn 2: with ReturnToPrevious, specialist should be invoked directly, coordinator should NOT be called again
Assert.NotNull(turn1Result);
turn1Result.Add(new ChatMessage(ChatRole.User, "my id is 12345"));
_ = await RunWorkflowAsync(workflow, turn1Result, environment, sessionId);
Assert.Equal(1, coordinatorCallCount); // coordinator NOT called again
Assert.Equal(2, specialistCallCount); // specialist called again
}
[Fact]
public async Task Handoffs_ReturnToPrevious_Enabled_BeforeAnyHandoff_RoutesViaInitialAgentAsync()
{
int coordinatorCallCount = 0;
var coordinator = new ChatClientAgent(new MockChatClient((messages, options) =>
{
coordinatorCallCount++;
return new(new ChatMessage(ChatRole.Assistant, "coordinator responded"));
}), name: "coordinator");
var specialist = new ChatClientAgent(new MockChatClient((messages, options) =>
{
Assert.Fail("Specialist should not be invoked.");
return new();
}), name: "specialist", description: "The specialist agent");
var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator)
.WithHandoff(coordinator, specialist)
.EnableReturnToPrevious()
.Build();
var environment = InProcessExecution.Lockstep;
string sessionId = Guid.NewGuid().ToString("N");
// First turn with no prior handoff: should route to initial (coordinator) agent
_ = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "hello")], environment, sessionId);
Assert.Equal(1, coordinatorCallCount);
}
[Fact]
public async Task Handoffs_ReturnToPrevious_Enabled_AfterHandoffBackToCoordinator_NextTurnRoutesViaCoordinatorAsync()
{
int coordinatorCallCount = 0;
int specialistCallCount = 0;
var coordinator = new ChatClientAgent(new MockChatClient((messages, options) =>
{
coordinatorCallCount++;
if (coordinatorCallCount == 1)
{
// First call: hand off to specialist
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
Assert.NotNull(transferFuncName);
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)]));
}
// Subsequent calls: respond without handoff
return new(new ChatMessage(ChatRole.Assistant, "coordinator responded"));
}), name: "coordinator");
var specialist = new ChatClientAgent(new MockChatClient((messages, options) =>
{
specialistCallCount++;
// Specialist hands back to coordinator
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
Assert.NotNull(transferFuncName);
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", transferFuncName)]));
}), name: "specialist", description: "The specialist agent");
var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator)
.WithHandoff(coordinator, specialist)
.WithHandoff(specialist, coordinator)
.EnableReturnToPrevious()
.Build();
var environment = InProcessExecution.Lockstep;
string sessionId = Guid.NewGuid().ToString("N");
// Turn 1: coordinator → specialist → coordinator (specialist hands back)
(_, List<ChatMessage>? turn1Result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], environment, sessionId);
Assert.Equal(2, coordinatorCallCount); // called twice: initial handoff + receiving handback
Assert.Equal(1, specialistCallCount); // specialist called once, then handed back
// Turn 2: after handoff back to coordinator, should route to coordinator (not specialist)
Assert.NotNull(turn1Result);
turn1Result.Add(new ChatMessage(ChatRole.User, "never mind"));
_ = await RunWorkflowAsync(workflow, turn1Result, environment, sessionId);
Assert.Equal(3, coordinatorCallCount); // coordinator called again on turn 2
Assert.Equal(1, specialistCallCount); // specialist NOT called
}
private static async Task<(string UpdateText, List<ChatMessage>? Result)> RunWorkflowAsync(
Workflow workflow, List<ChatMessage> input, InProcessExecutionEnvironment environment, string? sessionId = null)
{
StringBuilder sb = new();
await using StreamingRun run = await environment.RunStreamingAsync(workflow, input, sessionId);
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
WorkflowOutputEvent? output = null;
await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false))
{
if (evt is AgentResponseUpdateEvent executorComplete)
{
sb.Append(executorComplete.Data);
}
else if (evt is WorkflowOutputEvent e)
{
output = e;
break;
}
else if (evt is WorkflowErrorEvent errorEvent)
{
Assert.Fail($"Workflow execution failed with error: {errorEvent.Exception}");
}
}
return (sb.ToString(), output?.As<List<ChatMessage>>());
}
private static async Task<(string UpdateText, List<ChatMessage>? Result)> RunWorkflowAsync(
Workflow workflow, List<ChatMessage> input, ExecutionEnvironment executionEnvironment = ExecutionEnvironment.InProcess_Lockstep)
{
@@ -794,7 +794,7 @@ public class WorkflowHostSmokeTests : AIAgentHostingExecutorTestsBase
{
// Arrange
TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName);
Workflow handoffWorkflow = new HandoffsWorkflowBuilder(agent).Build();
Workflow handoffWorkflow = new HandoffWorkflowBuilder(agent).Build();
return this.Run_AsAgent_OutgoingMessagesInHistoryAsync(handoffWorkflow, runAsync);
}
}