diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs
index 501c7df230..22ee1b48eb 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs
@@ -145,7 +145,7 @@ public static partial class AgentWorkflowBuilder
return builder.Build();
}
- /// Creates a new using as the starting agent in the workflow.
+ /// Creates a new using as the starting agent in the workflow.
/// The agent that will receive inputs provided to the workflow.
/// The builder for creating a workflow based on handoffs.
///
@@ -154,7 +154,7 @@ public static partial class AgentWorkflowBuilder
/// The must be capable of understanding those provided. If the agent
/// ignores the tools or is otherwise unable to advertize them to the underlying provider, handoffs will not occur.
///
- public static HandoffsWorkflowBuilder CreateHandoffBuilderWith(AIAgent initialAgent)
+ public static HandoffWorkflowBuilder CreateHandoffBuilderWith(AIAgent initialAgent)
{
Throw.IfNull(initialAgent);
return new(initialAgent);
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs
index ccb993b188..8e5cee7ed5 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs
@@ -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;
+///
+[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(initialAgent)
+{
+}
+
+///
+public sealed class HandoffWorkflowBuilder(AIAgent initialAgent) : HandoffWorkflowBuilderCore(initialAgent)
+{
+}
+
///
/// Provides a builder for specifying the handoff relationships between agents and building the resulting workflow.
///
-public sealed class HandoffsWorkflowBuilder
+public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkflowBuilderCore
{
///
/// The prefix for function calls that trigger handoffs to other agents; the full name is then `{FunctionPrefix}<agent_id>`,
@@ -26,12 +38,13 @@ public sealed class HandoffsWorkflowBuilder
private bool _emitAgentResponseEvents;
private bool _emitAgentResponseUpdateEvents;
private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly;
+ private bool _returnToPrevious;
///
/// Initializes a new instance of the class with no handoff relationships.
///
/// The first agent to be invoked (prior to any handoff).
- internal HandoffsWorkflowBuilder(AIAgent initialAgent)
+ internal HandoffWorkflowBuilderCore(AIAgent initialAgent)
{
this._initialAgent = initialAgent;
this._allAgents.Add(initialAgent);
@@ -63,10 +76,10 @@ public sealed class HandoffsWorkflowBuilder
/// constant.
///
/// The instructions to provide, or to restore the default instructions.
- public HandoffsWorkflowBuilder WithHandoffInstructions(string? instructions)
+ public TBuilder WithHandoffInstructions(string? instructions)
{
this.HandoffInstructions = instructions ?? DefaultHandoffInstructions;
- return this;
+ return (TBuilder)this;
}
///
@@ -75,10 +88,10 @@ public sealed class HandoffsWorkflowBuilder
///
///
///
- public HandoffsWorkflowBuilder EmitAgentResponseUpdateEvents(bool emitAgentResponseUpdateEvents = true)
+ public TBuilder EmitAgentResponseUpdateEvents(bool emitAgentResponseUpdateEvents = true)
{
this._emitAgentResponseUpdateEvents = emitAgentResponseUpdateEvents;
- return this;
+ return (TBuilder)this;
}
///
@@ -86,10 +99,10 @@ public sealed class HandoffsWorkflowBuilder
///
///
///
- public HandoffsWorkflowBuilder EmitAgentResponseEvents(bool emitAgentResponseEvents = true)
+ public TBuilder EmitAgentResponseEvents(bool emitAgentResponseEvents = true)
{
this._emitAgentResponseEvents = emitAgentResponseEvents;
- return this;
+ return (TBuilder)this;
}
///
@@ -97,10 +110,21 @@ public sealed class HandoffsWorkflowBuilder
/// s flowing through the handoff workflow. Defaults to .
///
/// The filtering behavior to apply.
- public HandoffsWorkflowBuilder WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior behavior)
+ public TBuilder WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior behavior)
{
this._toolCallFilteringBehavior = behavior;
- return this;
+ return (TBuilder)this;
+ }
+
+ ///
+ /// 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.
+ ///
+ /// The updated instance.
+ public TBuilder EnableReturnToPrevious()
+ {
+ this._returnToPrevious = true;
+ return (TBuilder)this;
}
///
@@ -110,7 +134,7 @@ public sealed class HandoffsWorkflowBuilder
/// The target agents to add as handoff targets for the source agent.
/// The updated instance.
/// The handoff reason for each target in is derived from that agent's description or name.
- public HandoffsWorkflowBuilder WithHandoffs(AIAgent from, IEnumerable to)
+ public TBuilder WithHandoffs(AIAgent from, IEnumerable to)
{
Throw.IfNull(from);
Throw.IfNull(to);
@@ -125,7 +149,7 @@ public sealed class HandoffsWorkflowBuilder
this.WithHandoff(from, target);
}
- return this;
+ return (TBuilder)this;
}
///
@@ -138,7 +162,7 @@ public sealed class HandoffsWorkflowBuilder
/// If , the reason is derived from 's description or name.
///
/// The updated instance.
- public HandoffsWorkflowBuilder WithHandoffs(IEnumerable from, AIAgent to, string? handoffReason = null)
+ public TBuilder WithHandoffs(IEnumerable 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;
}
///
@@ -166,7 +190,7 @@ public sealed class HandoffsWorkflowBuilder
/// If , the reason is derived from 's description or name.
///
/// The updated instance.
- 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;
}
///
@@ -206,8 +230,8 @@ public sealed class HandoffsWorkflowBuilder
/// The workflow built based on the handoffs in the builder.
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 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(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)
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs
index 21519e07ee..98205a40c6 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs
@@ -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 FilterMessages(List messages)
@@ -173,6 +173,7 @@ internal sealed class HandoffAgentExecutor(
private readonly AIAgent _agent = agent;
private readonly HashSet _handoffFunctionNames = [];
+ private readonly Dictionary _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)
{
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs
index cc4d87d21a..56e2fef9df 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs
@@ -8,4 +8,5 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
internal sealed record class HandoffState(
TurnToken TurnToken,
string? InvokedHandoff,
- List Messages);
+ List Messages,
+ string? CurrentAgentId = null);
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs
index 69f81376be..4a43c00a72 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs
@@ -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;
/// Executor used at the end of a handoff workflow to raise a final completed event.
-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((handoff, context, cancellationToken) =>
- context.YieldOutputAsync(handoff.Messages, cancellationToken)))
+ this.HandleAsync(handoff, context, cancellationToken)))
.YieldsOutput>();
+ private async ValueTask HandleAsync(HandoffState handoff, IWorkflowContext context, CancellationToken cancellationToken)
+ {
+ if (returnToPrevious)
+ {
+ await context.QueueStateUpdateAsync(HandoffConstants.CurrentAgentTrackerKey,
+ handoff.CurrentAgentId,
+ HandoffConstants.CurrentAgentTrackerScope,
+ cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ await context.YieldOutputAsync(handoff.Messages, cancellationToken).ConfigureAwait(false);
+ }
+
public ValueTask ResetAsync() => default;
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs
index 9039e86f5b..87c3b4566b 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs
@@ -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";
+}
+
/// Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token.
-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();
protected override ValueTask TakeTurnAsync(List 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();
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs
index 77d8d0a88d..7f06145a8e 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs
@@ -147,7 +147,7 @@ public class AgentWorkflowBuilderTests
for (int iter = 0; iter < 3; iter++)
{
const string UserInput = "abc";
- (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
+ (string updateText, List? 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(TaskCreationOptions.RunContinuationsAsynchronously);
remaining.Value = 2;
- (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
+ (string updateText, List? 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? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
+ (string updateText, List? 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? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
+ (string updateText, List? 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? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
+ (string updateText, List? 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? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
+ (string updateText, List? 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? Result)> RunWorkflowAsync(
- Workflow workflow, List 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? Result, CheckpointInfo? LastCheckpoint);
+
+ private static Task RunWorkflowCheckpointedAsync(
+ Workflow workflow, List input, ExecutionEnvironment executionEnvironment, CheckpointManager checkpointManager, CheckpointInfo? fromCheckpoint = null)
+ {
+ InProcessExecutionEnvironment environment = executionEnvironment.ToWorkflowExecutionEnvironment()
+ .WithCheckpointing(checkpointManager);
+
+ return RunWorkflowCheckpointedAsync(workflow, input, environment, fromCheckpoint);
+ }
+
+ private static async Task RunWorkflowCheckpointedAsync(
+ Workflow workflow, List 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>());
+ return new(sb.ToString(), output?.As>(), lastCheckpoint);
}
+ private static Task RunWorkflowAsync(
+ Workflow workflow, List input, ExecutionEnvironment executionEnvironment = ExecutionEnvironment.InProcess_Lockstep)
+ => RunWorkflowCheckpointedAsync(workflow, input, executionEnvironment.ToWorkflowExecutionEnvironment());
+
private sealed class DoubleEchoAgentWithBarrier(string name, StrongBox> barrier, StrongBox remaining) : DoubleEchoAgent(name)
{
protected override async IAsyncEnumerable RunCoreStreamingAsync(
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/12_HandOff_HostAsAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/12_HandOff_HostAsAgent.cs
index 3d88ed22ab..993a6d462b 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/12_HandOff_HostAsAgent.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/12_HandOff_HostAsAgent.cs
@@ -20,7 +20,7 @@ internal sealed class HandoffTestEchoAgent(string id, string name, string prefix
{
IEnumerable? 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();
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs
index 7bf00c4fb9..3fa17a34bb 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs
@@ -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);
}
}