.NET: feat: Implement return-to-previous routing in handoff workflow (#4356)

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

- Also obsoletes HandoffsWorkflowBuilder => HandoffWorkflowBuilder (no "s")

* refactor: Remove instance-shared current agent tracking in handoffs

Because the tracker was instance-shared between the start and end executors, it would be shared between all sessions, resulting in incorrect behaviour.

The corect way to do this is to keep the data in a shared executor scope, which is per-session.

* fix: Fix test logic for Handoff to correctly use checkpointing for multiturn
This commit is contained in:
Jacob Alber
2026-03-26 16:17:57 -04:00
committed by GitHub
Unverified
parent 9bfa593ae7
commit 5530bc536b
9 changed files with 323 additions and 58 deletions
@@ -145,7 +145,7 @@ public static partial class AgentWorkflowBuilder
return builder.Build();
}
/// <summary>Creates a new <see cref="HandoffsWorkflowBuilder"/> using <paramref name="initialAgent"/> as the starting agent in the workflow.</summary>
/// <summary>Creates a new <see cref="HandoffWorkflowBuilder"/> using <paramref name="initialAgent"/> as the starting agent in the workflow.</summary>
/// <param name="initialAgent">The agent that will receive inputs provided to the workflow.</param>
/// <returns>The builder for creating a workflow based on handoffs.</returns>
/// <remarks>
@@ -154,7 +154,7 @@ public static partial class AgentWorkflowBuilder
/// The <see cref="AIAgent"/> must be capable of understanding those <see cref="AgentRunOptions"/> provided. If the agent
/// ignores the tools or is otherwise unable to advertize them to the underlying provider, handoffs will not occur.
/// </remarks>
public static HandoffsWorkflowBuilder CreateHandoffBuilderWith(AIAgent initialAgent)
public static HandoffWorkflowBuilder CreateHandoffBuilderWith(AIAgent initialAgent)
{
Throw.IfNull(initialAgent);
return new(initialAgent);
@@ -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);
@@ -1,20 +1,35 @@
// 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(bool returnToPrevious) : 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 (returnToPrevious)
{
await context.QueueStateUpdateAsync<string?>(HandoffConstants.CurrentAgentTrackerKey,
handoff.CurrentAgentId,
HandoffConstants.CurrentAgentTrackerScope,
cancellationToken)
.ConfigureAwait(false);
}
await context.YieldOutputAsync(handoff.Messages, cancellationToken).ConfigureAwait(false);
}
public ValueTask ResetAsync() => default;
}
@@ -7,8 +7,14 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
internal static class HandoffConstants
{
internal const string CurrentAgentTrackerKey = "LastAgentId";
internal const string CurrentAgentTrackerScope = "HandoffOrchestration";
}
/// <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(bool returnToPrevious) : ChatProtocolExecutor(ExecutorId, DefaultOptions, declareCrossRunShareable: true), IResettableExecutor
{
internal const string ExecutorId = "HandoffStart";
@@ -22,7 +28,25 @@ 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);
{
if (returnToPrevious)
{
return context.InvokeWithStateAsync(
async (string? currentAgentId, IWorkflowContext context, CancellationToken cancellationToken) =>
{
HandoffState handoffState = new(new(emitEvents), null, messages, currentAgentId);
await context.SendMessageAsync(handoffState, cancellationToken).ConfigureAwait(false);
return currentAgentId;
},
HandoffConstants.CurrentAgentTrackerKey,
HandoffConstants.CurrentAgentTrackerScope,
cancellationToken);
}
HandoffState handoff = new(new(emitEvents), null, messages);
return context.SendMessageAsync(handoff, cancellationToken);
}
public new ValueTask ResetAsync() => base.ResetAsync();
}
@@ -147,7 +147,7 @@ public class AgentWorkflowBuilderTests
for (int iter = 0; iter < 3; iter++)
{
const string UserInput = "abc";
(string updateText, List<ChatMessage>? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
(string updateText, List<ChatMessage>? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
Assert.NotNull(result);
Assert.Equal(numAgents + 1, result.Count);
@@ -225,7 +225,7 @@ public class AgentWorkflowBuilderTests
barrier.Value = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
remaining.Value = 2;
(string updateText, List<ChatMessage>? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
(string updateText, List<ChatMessage>? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
Assert.NotEmpty(updateText);
Assert.NotNull(result);
@@ -258,7 +258,7 @@ public class AgentWorkflowBuilderTests
}), description: "nop"))
.Build();
(string updateText, List<ChatMessage>? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
(string updateText, List<ChatMessage>? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
Assert.Equal("Hello from agent1", updateText);
Assert.NotNull(result);
@@ -296,7 +296,7 @@ public class AgentWorkflowBuilderTests
.WithHandoff(initialAgent, nextAgent)
.Build();
(string updateText, List<ChatMessage>? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
(string updateText, List<ChatMessage>? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
Assert.Equal("Hello from agent2", updateText);
Assert.NotNull(result);
@@ -406,7 +406,7 @@ public class AgentWorkflowBuilderTests
.WithHandoff(secondAgent, thirdAgent)
.Build();
(string updateText, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
(string updateText, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
Assert.Contains("Hello from agent3", updateText);
@@ -604,7 +604,7 @@ public class AgentWorkflowBuilderTests
.WithHandoff(secondAgent, thirdAgent)
.Build();
(string updateText, List<ChatMessage>? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
(string updateText, List<ChatMessage>? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
Assert.Equal("Hello from agent3", updateText);
Assert.NotNull(result);
@@ -651,7 +651,7 @@ public class AgentWorkflowBuilderTests
for (int iter = 0; iter < 3; iter++)
{
const string UserInput = "abc";
(string updateText, List<ChatMessage>? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
(string updateText, List<ChatMessage>? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
Assert.NotNull(result);
Assert.Equal(maxIterations + 1, result.Count);
@@ -680,36 +680,211 @@ public class AgentWorkflowBuilderTests
}
}
private static async Task<(string UpdateText, List<ChatMessage>? Result)> RunWorkflowAsync(
Workflow workflow, List<ChatMessage> input, ExecutionEnvironment executionEnvironment = ExecutionEnvironment.InProcess_Lockstep)
[Fact]
public async Task Handoffs_ReturnToPrevious_DisabledByDefault_SecondTurnRoutesViaCoordinatorAsync()
{
StringBuilder sb = new();
int coordinatorCallCount = 0;
InProcessExecutionEnvironment environment = executionEnvironment.ToWorkflowExecutionEnvironment();
await using StreamingRun run = await environment.RunStreamingAsync(workflow, input);
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();
CheckpointManager checkpointManager = CheckpointManager.CreateInMemory();
const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep;
// Turn 1: coordinator hands off to specialist
WorkflowRunResult result = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], Environment, checkpointManager);
Assert.Equal(1, coordinatorCallCount);
// Turn 2: without ReturnToPrevious, coordinator should be invoked again
_ = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "my id is 12345")], Environment, checkpointManager, result.LastCheckpoint);
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();
CheckpointManager checkpointManager = CheckpointManager.CreateInMemory();
const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep;
// Turn 1: coordinator hands off to specialist
WorkflowRunResult result = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], Environment, checkpointManager);
Assert.Equal(1, coordinatorCallCount);
Assert.Equal(1, specialistCallCount);
// Turn 2: with ReturnToPrevious, specialist should be invoked directly, coordinator should NOT be called again
_ = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "my id is 12345")], Environment, checkpointManager, result.LastCheckpoint);
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();
// First turn with no prior handoff: should route to initial (coordinator) agent
_ = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "hello")]);
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();
CheckpointManager checkpointManager = CheckpointManager.CreateInMemory();
const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep;
// Turn 1: coordinator → specialist → coordinator (specialist hands back)
WorkflowRunResult result = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], Environment, checkpointManager);
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)
_ = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "never mind")], Environment, checkpointManager, result.LastCheckpoint);
Assert.Equal(3, coordinatorCallCount); // coordinator called again on turn 2
Assert.Equal(1, specialistCallCount); // specialist NOT called
}
private sealed record WorkflowRunResult(string UpdateText, List<ChatMessage>? Result, CheckpointInfo? LastCheckpoint);
private static Task<WorkflowRunResult> RunWorkflowCheckpointedAsync(
Workflow workflow, List<ChatMessage> input, ExecutionEnvironment executionEnvironment, CheckpointManager checkpointManager, CheckpointInfo? fromCheckpoint = null)
{
InProcessExecutionEnvironment environment = executionEnvironment.ToWorkflowExecutionEnvironment()
.WithCheckpointing(checkpointManager);
return RunWorkflowCheckpointedAsync(workflow, input, environment, fromCheckpoint);
}
private static async Task<WorkflowRunResult> RunWorkflowCheckpointedAsync(
Workflow workflow, List<ChatMessage> input, InProcessExecutionEnvironment environment, CheckpointInfo? fromCheckpoint = null)
{
await using StreamingRun run =
fromCheckpoint != null ? await environment.ResumeStreamingAsync(workflow, fromCheckpoint)
: await environment.OpenStreamingAsync(workflow);
await run.TrySendMessageAsync(input);
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
StringBuilder sb = new();
WorkflowOutputEvent? output = null;
CheckpointInfo? lastCheckpoint = null;
await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false))
{
if (evt is AgentResponseUpdateEvent executorComplete)
switch (evt)
{
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}");
case AgentResponseUpdateEvent executorComplete:
sb.Append(executorComplete.Data);
break;
case WorkflowOutputEvent e:
output = e;
break;
case WorkflowErrorEvent errorEvent:
Assert.Fail($"Workflow execution failed with error: {errorEvent.Exception}");
break;
case SuperStepCompletedEvent stepCompleted:
lastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint;
break;
}
}
return (sb.ToString(), output?.As<List<ChatMessage>>());
return new(sb.ToString(), output?.As<List<ChatMessage>>(), lastCheckpoint);
}
private static Task<WorkflowRunResult> RunWorkflowAsync(
Workflow workflow, List<ChatMessage> input, ExecutionEnvironment executionEnvironment = ExecutionEnvironment.InProcess_Lockstep)
=> RunWorkflowCheckpointedAsync(workflow, input, executionEnvironment.ToWorkflowExecutionEnvironment());
private sealed class DoubleEchoAgentWithBarrier(string name, StrongBox<TaskCompletionSource<bool>> barrier, StrongBox<int> remaining) : DoubleEchoAgent(name)
{
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
@@ -20,7 +20,7 @@ internal sealed class HandoffTestEchoAgent(string id, string name, string prefix
{
IEnumerable<AITool>? handoffs = chatClientOptions.ChatOptions
.Tools?
.Where(tool => tool.Name?.StartsWith(HandoffsWorkflowBuilder.FunctionPrefix,
.Where(tool => tool.Name?.StartsWith(HandoffWorkflowBuilder.FunctionPrefix,
StringComparison.OrdinalIgnoreCase) is true);
if (handoffs != null)
@@ -58,7 +58,7 @@ internal static class Step12EntryPoint
.Select(i => new HandoffTestEchoAgent($"{EchoAgentIdPrefix}{i}", $"{EchoAgentNamePrefix}{i}", EchoPrefixForAgent(i)))
.ToArray();
return new HandoffsWorkflowBuilder(echoAgents[0])
return new HandoffWorkflowBuilder(echoAgents[0])
.WithHandoff(echoAgents[0], echoAgents[1])
.Build();
}
@@ -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);
}
}