From 9fab1ea4ee43beaf806c8fcb80b95eefc2832847 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Fri, 27 Feb 2026 18:48:19 +0000 Subject: [PATCH] feat: Implement return-to-previous routing in handoff workflow - Also obsoletes HandoffsWorkflowBuilder => HandoffWorkflowBuilder (no "s") --- .../HandoffsWorkflowBuilder.cs | 57 +++++- .../Specialized/HandoffAgentExecutor.cs | 8 +- .../Specialized/HandoffState.cs | 3 +- .../HandoffsCurrentAgentTracker.cs | 9 + .../Specialized/HandoffsEndExecutor.cs | 15 +- .../Specialized/HandoffsStartExecutor.cs | 4 +- .../AgentWorkflowBuilderTests.cs | 190 ++++++++++++++++++ 7 files changed, 274 insertions(+), 12 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsCurrentAgentTracker.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs index bd0b3114f1..1e5c004fca 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,16 +9,28 @@ namespace Microsoft.Agents.AI.Workflows; +/// +[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.")] +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 { internal const string FunctionPrefix = "handoff_to_"; private readonly AIAgent _initialAgent; private readonly Dictionary> _targets = []; private readonly HashSet _allAgents = new(AIAgentIDEqualityComparer.Instance); private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly; + private bool _returnToPrevious; /// /// Initializes a new instance of the class with no handoff relationships. @@ -68,6 +81,17 @@ public HandoffsWorkflowBuilder WithToolCallFilteringBehavior(HandoffToolCallFilt return 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 HandoffsWorkflowBuilder EnableReturnToPrevious() + { + this._returnToPrevious = true; + return this; + } + /// /// Adds handoff relationships from a source agent to one or more target agents. /// @@ -171,17 +195,38 @@ public HandoffsWorkflowBuilder WithHandoff(AIAgent from, AIAgent to, string? han /// The workflow built based on the handoffs in the builder. public Workflow Build() { - HandoffsStartExecutor start = new(); - HandoffsEndExecutor end = new(); + HandoffsCurrentAgentTracker? tracker = this._returnToPrevious ? new() : null; + HandoffsStartExecutor start = new(tracker); + HandoffsEndExecutor end = new(tracker); WorkflowBuilder builder = new(start); HandoffAgentExecutorOptions options = new(this.HandoffInstructions, 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 d1367b83ad..08fc6c59d6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs @@ -167,6 +167,7 @@ internal sealed class HandoffAgentExecutor( private readonly AIAgent _agent = agent; private readonly HashSet _handoffFunctionNames = []; + private readonly Dictionary _handoffFunctionToAgentId = []; private ChatClientAgentRunOptions? _agentOptions; public void Initialize( @@ -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); @@ -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) { 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/HandoffsCurrentAgentTracker.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsCurrentAgentTracker.cs new file mode 100644 index 0000000000..51e3fd9475 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsCurrentAgentTracker.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Workflows.Specialized; + +/// Tracks the current agent ID across turns when return-to-previous routing is enabled. +internal sealed class HandoffsCurrentAgentTracker +{ + public string? CurrentAgentId { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs index 69f81376be..c892112453 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs @@ -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; /// 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(HandoffsCurrentAgentTracker? tracker = null) : 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 (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; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs index 9039e86f5b..119a0a778d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs @@ -8,7 +8,7 @@ namespace Microsoft.Agents.AI.Workflows.Specialized; /// 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(HandoffsCurrentAgentTracker? tracker = null) : ChatProtocolExecutor(ExecutorId, DefaultOptions, declareCrossRunShareable: true), IResettableExecutor { internal const string ExecutorId = "HandoffStart"; @@ -22,7 +22,7 @@ protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBui 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); + => context.SendMessageAsync(new HandoffState(new(emitEvents), null, messages, tracker?.CurrentAgentId), cancellationToken: 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 01ce7c3441..c78fdc41c7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs @@ -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? 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? 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? 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? Result)> RunWorkflowAsync( + Workflow workflow, List 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>()); + } + private static async Task<(string UpdateText, List? Result)> RunWorkflowAsync( Workflow workflow, List input, ExecutionEnvironment executionEnvironment = ExecutionEnvironment.InProcess_Lockstep) {