Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -8,22 +9,34 @@

namespace Microsoft.Agents.AI.Workflows;

/// <inheritdoc/>
[Obsolete("Perfer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed if a future release before GA.")]
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spelling error: "Perfer" should be "Prefer" in the obsolescence message.

Suggested change
[Obsolete("Perfer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed if a future release before GA.")]
[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.")]

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Grammatical error: "This will be removed if a future release" should be "This will be removed in a future release".

Suggested change
[Obsolete("Perfer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed if a future release before GA.")]
[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.")]

Copilot uses AI. Check for mistakes.
public sealed class HandoffsWorkflowBuilder(AIAgent initialAgent) : HandoffWorkflowBuilderCore(initialAgent)
{
}

/// <inheritdoc/>
public sealed class HandoffWorkflowBuilder(AIAgent initialAgent) : HandoffWorkflowBuilderCore(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
{
internal 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 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)

Check failure on line 39 in dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net9.0, windows-latest, Debug)

Method must have a return type

Check failure on line 39 in dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net9.0, windows-latest, Debug)

Method must have a return type

Check failure on line 39 in dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net8.0, ubuntu-latest, Release)

Method must have a return type

Check failure on line 39 in dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net8.0, ubuntu-latest, Release)

Method must have a return type

Check failure on line 39 in dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net472, windows-latest, Release, true, integration)

Method must have a return type

Check failure on line 39 in dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net472, windows-latest, Release, true, integration)

Method must have a return type

Check failure on line 39 in dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net10.0, ubuntu-latest, Release, true, integration)

Method must have a return type

Check failure on line 39 in dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net10.0, ubuntu-latest, Release, true, integration)

Method must have a return type
{
this._initialAgent = initialAgent;
this._allAgents.Add(initialAgent);
Expand Down Expand Up @@ -51,7 +64,7 @@
/// perform them.
/// </summary>
/// <param name="instructions">The instructions to provide, or <see langword="null"/> to restore the default instructions.</param>
public HandoffsWorkflowBuilder WithHandoffInstructions(string? instructions)

Check failure on line 67 in dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net9.0, windows-latest, Debug)

'HandoffsWorkflowBuilder' is obsolete: 'Perfer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed if a future release before GA.'

Check failure on line 67 in dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net8.0, ubuntu-latest, Release)

'HandoffsWorkflowBuilder' is obsolete: 'Perfer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed if a future release before GA.'

Check failure on line 67 in dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net472, windows-latest, Release, true, integration)

'HandoffsWorkflowBuilder' is obsolete: 'Perfer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed if a future release before GA.'

Check failure on line 67 in dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net10.0, ubuntu-latest, Release, true, integration)

'HandoffsWorkflowBuilder' is obsolete: 'Perfer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed if a future release before GA.'
{
this.HandoffInstructions = instructions ?? DefaultHandoffInstructions;
return this;
Expand All @@ -62,12 +75,23 @@
/// <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)

Check failure on line 78 in dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net9.0, windows-latest, Debug)

'HandoffsWorkflowBuilder' is obsolete: 'Perfer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed if a future release before GA.'

Check failure on line 78 in dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net8.0, ubuntu-latest, Release)

'HandoffsWorkflowBuilder' is obsolete: 'Perfer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed if a future release before GA.'

Check failure on line 78 in dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net472, windows-latest, Release, true, integration)

'HandoffsWorkflowBuilder' is obsolete: 'Perfer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed if a future release before GA.'

Check failure on line 78 in dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net10.0, ubuntu-latest, Release, true, integration)

'HandoffsWorkflowBuilder' is obsolete: 'Perfer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed if a future release before GA.'
{
this._toolCallFilteringBehavior = behavior;
return 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 HandoffsWorkflowBuilder EnableReturnToPrevious()

Check failure on line 89 in dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net9.0, windows-latest, Debug)

'HandoffsWorkflowBuilder' is obsolete: 'Perfer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed if a future release before GA.'

Check failure on line 89 in dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net8.0, ubuntu-latest, Release)

'HandoffsWorkflowBuilder' is obsolete: 'Perfer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed if a future release before GA.'

Check failure on line 89 in dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net472, windows-latest, Release, true, integration)

'HandoffsWorkflowBuilder' is obsolete: 'Perfer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed if a future release before GA.'

Check failure on line 89 in dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net10.0, ubuntu-latest, Release, true, integration)

'HandoffsWorkflowBuilder' is obsolete: 'Perfer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed if a future release before GA.'
{
this._returnToPrevious = true;
return this;
}

/// <summary>
/// Adds handoff relationships from a source agent to one or more target agents.
/// </summary>
Expand All @@ -75,7 +99,7 @@
/// <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)

Check failure on line 102 in dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net9.0, windows-latest, Debug)

'HandoffsWorkflowBuilder' is obsolete: 'Perfer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed if a future release before GA.'

Check failure on line 102 in dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net8.0, ubuntu-latest, Release)

'HandoffsWorkflowBuilder' is obsolete: 'Perfer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed if a future release before GA.'

Check failure on line 102 in dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net472, windows-latest, Release, true, integration)

'HandoffsWorkflowBuilder' is obsolete: 'Perfer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed if a future release before GA.'

Check failure on line 102 in dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net10.0, ubuntu-latest, Release, true, integration)

'HandoffsWorkflowBuilder' is obsolete: 'Perfer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed if a future release before GA.'
{
Throw.IfNull(from);
Throw.IfNull(to);
Expand Down Expand Up @@ -103,7 +127,7 @@
/// 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)

Check failure on line 130 in dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net9.0, windows-latest, Debug)

'HandoffsWorkflowBuilder' is obsolete: 'Perfer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed if a future release before GA.'

Check failure on line 130 in dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net8.0, ubuntu-latest, Release)

'HandoffsWorkflowBuilder' is obsolete: 'Perfer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed if a future release before GA.'

Check failure on line 130 in dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net472, windows-latest, Release, true, integration)

'HandoffsWorkflowBuilder' is obsolete: 'Perfer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed if a future release before GA.'

Check failure on line 130 in dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net10.0, ubuntu-latest, Release, true, integration)

'HandoffsWorkflowBuilder' is obsolete: 'Perfer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed if a future release before GA.'
{
Throw.IfNull(from);
Throw.IfNull(to);
Expand Down Expand Up @@ -131,7 +155,7 @@
/// 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)

Check failure on line 158 in dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net9.0, windows-latest, Debug)

'HandoffsWorkflowBuilder' is obsolete: 'Perfer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed if a future release before GA.'

Check failure on line 158 in dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net8.0, ubuntu-latest, Release)

'HandoffsWorkflowBuilder' is obsolete: 'Perfer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed if a future release before GA.'

Check failure on line 158 in dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net472, windows-latest, Release, true, integration)

'HandoffsWorkflowBuilder' is obsolete: 'Perfer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed if a future release before GA.'

Check failure on line 158 in dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net10.0, ubuntu-latest, Release, true, integration)

'HandoffsWorkflowBuilder' is obsolete: 'Perfer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed if a future release before GA.'
{
Throw.IfNull(from);
Throw.IfNull(to);
Expand Down Expand Up @@ -171,17 +195,38 @@
/// <returns>The workflow built based on the handoffs in the builder.</returns>
public Workflow Build()
{
HandoffsStartExecutor start = new();
HandoffsEndExecutor end = new();
HandoffsCurrentAgentTracker? tracker = this._returnToPrevious ? new() : null;
HandoffsStartExecutor start = new(tracker);
HandoffsEndExecutor end = new(tracker);
Comment on lines +198 to +200
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The HandoffsCurrentAgentTracker is created once per workflow and will be shared if the same workflow instance is reused across multiple sessions. This means the last active agent from one session could become the starting agent for a different session. This is likely fine for the common use case (one workflow instance per conversation session), but could cause unexpected behavior if workflows are reused. Consider documenting that workflows built with EnableReturnToPrevious should not be reused across different conversation sessions, or making the tracker session-scoped.

Copilot uses AI. Check for mistakes.
WorkflowBuilder builder = new(start);

HandoffAgentExecutorOptions options = new(this.HandoffInstructions, 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,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(
Expand Down Expand Up @@ -196,6 +197,7 @@ public void Initialize(
var handoffFunc = AIFunctionFactory.CreateDeclaration($"{HandoffsWorkflowBuilder.FunctionPrefix}{index}", handoff.Reason, s_handoffSchema);

this._handoffFunctionNames.Add(handoffFunc.Name);
this._handoffFunctionToAgentId[handoffFunc.Name] = handoff.Target.Id;

this._agentOptions.ChatOptions.Tools.Add(handoffFunc);

Expand Down Expand Up @@ -254,7 +256,11 @@ await AddUpdateAsync(

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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Original file line number Diff line number Diff line change
@@ -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; }
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
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";

Expand All @@ -22,7 +22,7 @@ protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBui
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();
}
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,196 @@ public async Task BuildGroupChat_AgentsRunInOrderAsync(int maxIterations)
}
}

[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)
{
Expand Down
Loading