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.
This commit is contained in:
Jacob Alber
2026-03-19 16:16:09 +00:00
Unverified
parent 0079a92324
commit 249a43c0e9
5 changed files with 37 additions and 18 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,9 +0,0 @@
// 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; }
}
@@ -8,7 +8,7 @@ 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(HandoffsCurrentAgentTracker? tracker = null) : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor
internal sealed class HandoffsEndExecutor(bool returnToPrevious) : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor
{
public const string ExecutorId = "HandoffEnd";
@@ -19,9 +19,13 @@ internal sealed class HandoffsEndExecutor(HandoffsCurrentAgentTracker? tracker =
private async ValueTask HandleAsync(HandoffState handoff, IWorkflowContext context, CancellationToken cancellationToken)
{
if (tracker is not null && handoff.CurrentAgentId is not null)
if (returnToPrevious)
{
tracker.CurrentAgentId = handoff.CurrentAgentId;
await context.QueueStateUpdateAsync<string?>(HandoffConstants.CurrentAgentTrackerKey,
handoff.CurrentAgentId,
HandoffConstants.CurrentAgentTrackerScope,
cancellationToken)
.ConfigureAwait(false);
}
await context.YieldOutputAsync(handoff.Messages, cancellationToken).ConfigureAwait(false);
@@ -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(HandoffsCurrentAgentTracker? tracker = null) : 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(HandoffsCurrentAgentTracker? tracker
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, tracker?.CurrentAgentId), 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();
}
@@ -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();
}