.NET: feat: Bring Handoff Orchestration to parity with Python (#6138)

* feat: implement autonomous mode and termination conditions in handoff workflow

* fixup: format

* feat: enhance autonomous mode with per-agent configurations and add unit tests

* fixup: remove empty file

---------

Co-authored-by: Jacob Alber <jalber@lokitoth.com>
This commit is contained in:
Jacob Alber
2026-05-28 14:04:15 -04:00
committed by GitHub
Unverified
parent 401a552735
commit 945647a065
6 changed files with 759 additions and 24 deletions
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
@@ -57,6 +58,22 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
private string? _name;
private string? _description;
// Autonomous mode configuration. When enabled, an agent's response that doesn't include a
// handoff triggers another invocation of that same agent with the continuation prompt, up to
// the configured turn limit per workflow turn. Optional per-agent overrides may further restrict
// which agents have autonomous mode enabled, or override the turn limit / continuation prompt
// on a per-agent basis.
private bool _autonomousMode;
private int _autonomousTurnLimit = HandoffWorkflowBuilderDefaults.DefaultAutonomousTurnLimit;
private string _autonomousContinuationPrompt = HandoffWorkflowBuilderDefaults.DefaultAutonomousContinuationPrompt;
private HashSet<string>? _autonomousEnabledAgentIds;
private readonly Dictionary<string, int> _autonomousTurnLimitsByAgentId = [];
private readonly Dictionary<string, string> _autonomousContinuationPromptsByAgentId = [];
// Termination condition. Evaluated after an agent response that does not request a handoff;
// if true, the workflow ends (and the autonomous loop, if any, terminates).
private Func<IReadOnlyList<ChatMessage>, ValueTask<bool>>? _terminationCondition;
/// <summary>
/// Initializes a new instance of the <see cref="HandoffsWorkflowBuilder"/> class with no handoff relationships.
/// </summary>
@@ -258,12 +275,204 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
return (TBuilder)this;
}
private Dictionary<string, ExecutorBinding> CreateExecutorBindings(WorkflowBuilder builder)
/// <summary>
/// Adds the specified <paramref name="agents"/> as participants in the handoff workflow without
/// defining handoff relationships for them.
/// </summary>
/// <param name="agents">The agents to add as participants.</param>
/// <returns>The updated builder instance.</returns>
/// <remarks>
/// Use this method when you want a participant to be part of the workflow but you have not
/// explicitly defined handoff edges via <see cref="WithHandoff(AIAgent, AIAgent, string?)"/>.
/// When no handoffs are explicitly defined (default handoffs), all registered participants are
/// automatically wired so that every agent can hand off to every other agent.
/// </remarks>
public TBuilder AddParticipants(params IEnumerable<AIAgent> agents)
{
Throw.IfNull(agents);
foreach (AIAgent agent in agents)
{
if (agent is null)
{
Throw.ArgumentNullException(nameof(agents), "One or more agents are null.");
}
this._allAgents.Add(agent);
}
return (TBuilder)this;
}
/// <summary>
/// Enables autonomous mode for the handoff workflow.
/// </summary>
/// <remarks>
/// <para>
/// In autonomous mode, an agent whose response does not include a handoff is invoked again with
/// a continuation prompt, up to a configured turn limit. The autonomous loop for a given agent
/// ends when the agent invokes a handoff tool, the configured termination condition fires, or
/// the per-agent turn limit is reached — at which point the workflow yields control back to the
/// caller.
/// </para>
/// <para>
/// <b>Per-agent turn counting.</b> Autonomous-turn counters are tracked independently per agent
/// in the shared handoff state. A counter is incremented each time the End executor loops
/// control back to its source agent, and reset to zero in three cases: (1) when that agent
/// requests a handoff, (2) when its autonomous loop terminates (limit reached, termination
/// fires, or autonomous mode disabled for that agent), and (3) at the start of every fresh user
/// turn. As a consequence, if agent A loops twice and then hands off to B, A's counter resets
/// to zero; should control later return to A within the same user turn, A starts a new
/// autonomous run from zero.
/// </para>
/// </remarks>
/// <param name="turnLimit">
/// The default maximum number of autonomous continuation iterations per agent per workflow
/// turn. Applies to agents not listed in <paramref name="agentTurnLimits"/>. If
/// <see langword="null"/>, defaults to
/// <see cref="HandoffWorkflowBuilderDefaults.DefaultAutonomousTurnLimit"/> (50).
/// </param>
/// <param name="continuationPrompt">
/// The default user-role prompt fed to an agent on each autonomous continuation. Applies to
/// agents not listed in <paramref name="agentContinuationPrompts"/>. If <see langword="null"/>,
/// defaults to <see cref="HandoffWorkflowBuilderDefaults.DefaultAutonomousContinuationPrompt"/>.
/// </param>
/// <param name="agents">
/// Optional allow-list restricting autonomous mode to a specific subset of agents. If
/// <see langword="null"/> or empty, autonomous mode is enabled for <i>every</i> participant.
/// Agents not in the allow-list always yield control back to the caller after a single
/// invocation (when they do not request a handoff).
/// </param>
/// <param name="agentTurnLimits">
/// Optional per-agent turn-limit overrides. Each entry's key is the agent and its value the
/// turn limit that overrides <paramref name="turnLimit"/> for that agent. Agents not present
/// fall back to the default.
/// </param>
/// <param name="agentContinuationPrompts">
/// Optional per-agent continuation-prompt overrides. Each entry's key is the agent and its
/// value the continuation prompt used for that agent. Agents not present fall back to the
/// default.
/// </param>
/// <returns>The updated builder instance.</returns>
public TBuilder WithAutonomousMode(
int? turnLimit = null,
string? continuationPrompt = null,
IEnumerable<AIAgent>? agents = null,
IReadOnlyDictionary<AIAgent, int>? agentTurnLimits = null,
IReadOnlyDictionary<AIAgent, string>? agentContinuationPrompts = null)
{
if (turnLimit is { } limit && limit <= 0)
{
Throw.ArgumentOutOfRangeException(nameof(turnLimit), "Turn limit must be greater than zero.");
}
this._autonomousMode = true;
this._autonomousTurnLimit = turnLimit ?? HandoffWorkflowBuilderDefaults.DefaultAutonomousTurnLimit;
this._autonomousContinuationPrompt = continuationPrompt ?? HandoffWorkflowBuilderDefaults.DefaultAutonomousContinuationPrompt;
// Allow-list: null or empty means every participant has autonomous mode enabled. A non-empty
// list restricts autonomous mode to exactly those agents.
this._autonomousEnabledAgentIds = null;
if (agents is not null)
{
HashSet<string> ids = [];
foreach (AIAgent agent in agents)
{
Throw.IfNull(agent, $"{nameof(agents)} element");
ids.Add(agent.Id);
}
if (ids.Count > 0)
{
this._autonomousEnabledAgentIds = ids;
}
}
this._autonomousTurnLimitsByAgentId.Clear();
if (agentTurnLimits is not null)
{
foreach (KeyValuePair<AIAgent, int> kvp in agentTurnLimits)
{
Throw.IfNull(kvp.Key, $"{nameof(agentTurnLimits)} key");
if (kvp.Value <= 0)
{
Throw.ArgumentOutOfRangeException(
nameof(agentTurnLimits),
$"Turn limit for agent '{kvp.Key.Name ?? kvp.Key.Id}' must be greater than zero.");
}
this._autonomousTurnLimitsByAgentId[kvp.Key.Id] = kvp.Value;
}
}
this._autonomousContinuationPromptsByAgentId.Clear();
if (agentContinuationPrompts is not null)
{
foreach (KeyValuePair<AIAgent, string> kvp in agentContinuationPrompts)
{
Throw.IfNull(kvp.Key, $"{nameof(agentContinuationPrompts)} key");
Throw.IfNullOrEmpty(kvp.Value, $"{nameof(agentContinuationPrompts)} value");
this._autonomousContinuationPromptsByAgentId[kvp.Key.Id] = kvp.Value;
}
}
return (TBuilder)this;
}
/// <summary>
/// Sets a synchronous termination condition for the handoff workflow.
/// </summary>
/// <param name="terminationCondition">
/// A predicate that receives the current conversation and returns <see langword="true"/> if the
/// workflow should terminate (preventing further autonomous continuation). The synchronous
/// predicate is wrapped and forwarded to the async overload.
/// </param>
/// <returns>The updated builder instance.</returns>
/// <remarks>
/// The termination condition is evaluated after the agent produces a response that does not
/// request a handoff. When it returns <see langword="true"/>, the workflow ends without invoking
/// another autonomous continuation.
/// </remarks>
public TBuilder WithTerminationCondition(Func<IReadOnlyList<ChatMessage>, bool> terminationCondition)
{
Throw.IfNull(terminationCondition);
return this.WithTerminationCondition(
messages => new ValueTask<bool>(terminationCondition(messages)));
}
/// <summary>
/// Sets an asynchronous termination condition for the handoff workflow.
/// </summary>
/// <param name="terminationCondition">
/// A predicate that receives the current conversation and asynchronously returns
/// <see langword="true"/> if the workflow should terminate (preventing further autonomous
/// continuation).
/// </param>
/// <returns>The updated builder instance.</returns>
/// <remarks>
/// The termination condition is evaluated after the agent produces a response that does not
/// request a handoff. When it returns <see langword="true"/>, the workflow ends without invoking
/// another autonomous continuation.
/// </remarks>
public TBuilder WithTerminationCondition(Func<IReadOnlyList<ChatMessage>, ValueTask<bool>> terminationCondition)
{
Throw.IfNull(terminationCondition);
this._terminationCondition = terminationCondition;
return (TBuilder)this;
}
private Dictionary<string, ExecutorBinding> CreateExecutorBindings(WorkflowBuilder builder, Dictionary<AIAgent, HashSet<HandoffTarget>> effectiveTargets)
{
HandoffAgentExecutorOptions options = new(this.HandoffInstructions,
this._emitAgentResponseEvents,
this._emitAgentResponseUpdateEvents,
this._toolCallFilteringBehavior);
this._toolCallFilteringBehavior)
{
TerminationCondition = this._terminationCondition,
};
// There are two types of ids being used in this method, and it is critical that we are clear about
// which one we are using, and where.
@@ -277,7 +486,7 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
ExecutorBinding CreateFactoryBinding(AIAgent agent)
{
if (!this._targets.TryGetValue(agent, out HashSet<HandoffTarget>? handoffs))
if (!effectiveTargets.TryGetValue(agent, out HashSet<HandoffTarget>? handoffs))
{
handoffs = new();
}
@@ -287,10 +496,16 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
{
foreach (HandoffTarget handoff in handoffs)
{
sb.AddCase<HandoffState>(state => state?.RequestedHandoffTargetAgentId == handoff.Target.Id, // Use AgentId for target matching
// Each handoff case also requires the turn to NOT be terminated; otherwise the
// turn falls through to the default branch, which routes to HandoffEndExecutor.
string targetAgentId = handoff.Target.Id;
sb.AddCase<HandoffState>(state => state?.RequestedHandoffTargetAgentId == targetAgentId // Use AgentId for target matching
&& state.IsTerminated != true,
HandoffAgentExecutor.IdFor(handoff.Target)); // Use ExecutorId in for routing at the workflow level
}
// Default branch catches: (a) turns with no handoff requested, and (b) terminated turns
// (whose handoff cases have been excluded above via the !IsTerminated guard).
sb.WithDefault(HandoffEndExecutor.ExecutorId);
});
@@ -309,6 +524,47 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
}
}
private Dictionary<AIAgent, HashSet<HandoffTarget>> BuildDefaultHandoffTargets()
{
// Default handoffs: when the caller has not explicitly registered any handoffs via
// WithHandoff/WithHandoffs, every registered participant is wired to hand off to every other
// participant.
// The handoff "reason" is derived from the target agent's description/name/instructions,
// matching the resolution rules used in WithHandoff(). If no reason can be derived, we throw —
// same contract as the explicit handoff path.
Dictionary<AIAgent, HashSet<HandoffTarget>> defaultTargets = [];
foreach (AIAgent source in this._allAgents)
{
HashSet<HandoffTarget> targets = [];
foreach (AIAgent target in this._allAgents)
{
if (AIAgentIDEqualityComparer.Instance.Equals(source, target))
{
continue;
}
string? reason = (string.IsNullOrWhiteSpace(target.Description) ? null : target.Description)
?? (string.IsNullOrWhiteSpace(target.Name) ? null : $"handoff to {target.Name}")
?? target.GetService<ChatClientAgent>()?.Instructions;
if (string.IsNullOrWhiteSpace(reason))
{
Throw.InvalidOperationException(
$"Cannot build default handoffs: target agent '{(string.IsNullOrWhiteSpace(target.Name) ? target.Id : target.Name)}' " +
"has no description, name, or instructions from which to derive a handoff reason. Either provide one of these " +
"on the agent, or define handoffs explicitly via WithHandoff/WithHandoffs.");
}
targets.Add(new HandoffTarget(target, reason));
}
defaultTargets[source] = targets;
}
return defaultTargets;
}
/// <summary>
/// Builds a <see cref="Workflow"/> composed of agents that operate via handoffs, with the next
/// agent to process messages selected by the current agent.
@@ -317,11 +573,25 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
public Workflow Build()
{
HandoffStartExecutor start = new(this._returnToPrevious);
HandoffEndExecutor end = new(this._returnToPrevious);
HandoffEndExecutor end = new(
returnToPrevious: this._returnToPrevious,
autonomousMode: this._autonomousMode,
autonomousTurnLimit: this._autonomousTurnLimit,
autonomousContinuationPrompt: this._autonomousContinuationPrompt,
autonomousEnabledAgentIds: this._autonomousEnabledAgentIds,
autonomousTurnLimitsByAgentId: this._autonomousTurnLimitsByAgentId,
autonomousContinuationPromptsByAgentId: this._autonomousContinuationPromptsByAgentId);
WorkflowBuilder builder = new(start);
// Default handoffs: when the caller has not explicitly registered any handoffs via
// WithHandoff/WithHandoffs, every registered participant is wired to hand off to every other
// participant.
Dictionary<AIAgent, HashSet<HandoffTarget>> effectiveTargets = this._targets.Count == 0
? this.BuildDefaultHandoffTargets()
: this._targets;
// Create an factory-based ExecutorBinding for each agent.
Dictionary<string, ExecutorBinding> executors = this.CreateExecutorBindings(builder);
Dictionary<string, ExecutorBinding> executors = this.CreateExecutorBindings(builder, effectiveTargets);
// Connect the start executor to the initial agent (or use dynamic routing when ReturnToPrevious is enabled).
if (this._returnToPrevious)
@@ -346,6 +616,21 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
builder.AddEdge(start, executors[this._initialAgent.Id]);
}
// Autonomous-mode loop-back: when enabled, the End executor may emit a HandoffState targeting
// the source agent (carrying the synthesized continuation prompt in the shared conversation).
// A switch downstream of End routes that message back to the matching agent executor.
if (this._autonomousMode)
{
builder.AddSwitch(end, sb =>
{
foreach (AIAgent agent in this._allAgents)
{
string agentId = agent.Id;
sb.AddCase<HandoffState>(state => state?.RequestedHandoffTargetAgentId == agentId, executors[agentId]);
}
});
}
if (!string.IsNullOrWhiteSpace(this._name))
{
builder.WithName(this._name);
@@ -30,6 +30,17 @@ internal sealed class HandoffAgentExecutorOptions
public bool? EmitAgentResponseUpdateEvents { get; set; }
public HandoffToolCallFilteringBehavior ToolCallFilteringBehavior { get; set; } = HandoffToolCallFilteringBehavior.HandoffOnly;
// Termination condition. When provided, evaluated after the agent responds and no handoff was
// requested. If it returns true, the outgoing HandoffState is stamped with IsTerminated = true
// so the per-agent routing switch routes the turn to HandoffEndExecutor instead of continuing.
public Func<IReadOnlyList<ChatMessage>, ValueTask<bool>>? TerminationCondition { get; set; }
}
internal static class HandoffWorkflowBuilderDefaults
{
public const string DefaultAutonomousContinuationPrompt = "User did not respond. Continue assisting autonomously.";
public const int DefaultAutonomousTurnLimit = 50;
}
internal struct AgentInvocationResult(AgentResponse agentResponse, string? handoffTargetId)
@@ -250,6 +261,7 @@ internal sealed class HandoffAgentExecutor :
}
int newConversationBookmark = state.ConversationBookmark;
List<ChatMessage>? conversationSnapshot = null;
await this._sharedStateRef.InvokeWithStateAsync(
(sharedState, ctx, ct) =>
{
@@ -285,12 +297,25 @@ internal sealed class HandoffAgentExecutor :
}
_ = sharedState.Conversation.AddMessage(handoffCallResultMessage);
// Reset this agent's autonomous-turn counter when it chooses to hand off, so that
// if control returns to this agent later in the turn (e.g. via another handoff),
// its autonomous loop starts fresh rather than carrying over prior iterations.
sharedState.AutonomousTurnsByAgent[this._agent.Id] = 0;
}
else
{
newConversationBookmark = sharedState.Conversation.AddMessages(result.Response.Messages);
}
// Snapshot the conversation for termination evaluation while we still hold shared state access.
// Termination is only relevant when no handoff was requested — a requested handoff always
// routes to the target agent regardless of termination.
if (this._options.TerminationCondition is not null && !result.IsHandoffRequested)
{
conversationSnapshot = sharedState.Conversation.CloneHistory();
}
return new ValueTask();
},
context,
@@ -298,18 +323,27 @@ internal sealed class HandoffAgentExecutor :
// We send on the HandoffState even if handoff is not requested because we might be terminating the processing, but this only
// happens if we have no outstanding requests.
if (!this.HasOutstandingRequests)
if (this.HasOutstandingRequests)
{
HandoffState outgoingState = new(state.IncomingState.TurnToken, result.HandoffTargetId, this._agent.Id);
await context.SendMessageAsync(outgoingState, cancellationToken).ConfigureAwait(false);
// reset the state for the next handoff, making sure to keep track of the conversation bookmark, and avoid resetting the
// agent session. (return-to-current is modeled as a new handoff turn, as opposed to "HITL", which can be a bit confusing.)
return state with { IncomingState = null, ConversationBookmark = newConversationBookmark };
return state with { ConversationBookmark = newConversationBookmark };
}
return state;
// Evaluate the termination condition (when configured and no handoff was requested) and stamp
// the result onto the outgoing HandoffState so the per-agent routing switch can route the turn
// to HandoffEndExecutor instead of dispatching another handoff or autonomous continuation.
bool isTerminated = false;
if (conversationSnapshot is not null)
{
isTerminated = await this._options.TerminationCondition!(conversationSnapshot).ConfigureAwait(false);
}
HandoffState outgoingState = new(state.IncomingState.TurnToken, result.HandoffTargetId, this._agent.Id, isTerminated);
await context.SendMessageAsync(outgoingState, cancellationToken).ConfigureAwait(false);
// Reset the turn-local state; keep the conversation bookmark and the agent session so the
// next invocation (handoff back, autonomous loop-back, or new user turn) resumes cleanly.
return state with { IncomingState = null, ConversationBookmark = newConversationBookmark };
}
public override ValueTask HandleAsync(HandoffState message, IWorkflowContext context, CancellationToken cancellationToken = default)
@@ -8,18 +8,76 @@ 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 HandoffEndExecutor(bool returnToPrevious) : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor
/// <summary>Executor used at the end of a handoff workflow to raise a final completed event,
/// and in autonomous mode to loop control back to the source agent.</summary>
/// <remarks>
/// Autonomous-turn counters are tracked per source agent in <see cref="HandoffSharedState.AutonomousTurnsByAgent"/>.
/// On each invocation where the source agent did not request a handoff and termination has not fired,
/// the counter for that agent is incremented and control is sent back to that agent (via the
/// autonomous-return switch wired downstream of this executor). When the counter reaches the per-agent
/// turn limit — or when termination fires, or when autonomous mode is disabled for that agent — the
/// counter is reset to zero and the conversation is yielded as workflow output.
/// </remarks>
internal sealed class HandoffEndExecutor : Executor, IResettableExecutor
{
public const string ExecutorId = "HandoffEnd";
private readonly bool _returnToPrevious;
private readonly bool _autonomousMode;
private readonly int _autonomousTurnLimit;
private readonly string _autonomousContinuationPrompt;
private readonly HashSet<string>? _autonomousEnabledAgentIds;
private readonly IReadOnlyDictionary<string, int> _autonomousTurnLimitsByAgentId;
private readonly IReadOnlyDictionary<string, string> _autonomousContinuationPromptsByAgentId;
private readonly StateRef<HandoffSharedState> _sharedStateRef = new(HandoffConstants.HandoffSharedStateKey,
HandoffConstants.HandoffSharedStateScope);
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) =>
protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<HandoffState>(
(handoff, context, cancellationToken) => this.HandleAsync(handoff, context, cancellationToken)))
.YieldsOutput<List<ChatMessage>>();
public HandoffEndExecutor(
bool returnToPrevious,
bool autonomousMode = false,
int autonomousTurnLimit = HandoffWorkflowBuilderDefaults.DefaultAutonomousTurnLimit,
string autonomousContinuationPrompt = HandoffWorkflowBuilderDefaults.DefaultAutonomousContinuationPrompt,
HashSet<string>? autonomousEnabledAgentIds = null,
IReadOnlyDictionary<string, int>? autonomousTurnLimitsByAgentId = null,
IReadOnlyDictionary<string, string>? autonomousContinuationPromptsByAgentId = null)
: base(ExecutorId, declareCrossRunShareable: true)
{
this._returnToPrevious = returnToPrevious;
this._autonomousMode = autonomousMode;
this._autonomousTurnLimit = autonomousTurnLimit;
this._autonomousContinuationPrompt = autonomousContinuationPrompt;
this._autonomousEnabledAgentIds = autonomousEnabledAgentIds;
this._autonomousTurnLimitsByAgentId = autonomousTurnLimitsByAgentId ?? new Dictionary<string, int>();
this._autonomousContinuationPromptsByAgentId = autonomousContinuationPromptsByAgentId ?? new Dictionary<string, string>();
}
private bool IsAutonomousEnabledFor(string agentId) =>
// Null allow-list means every participant has autonomous mode enabled.
this._autonomousEnabledAgentIds?.Contains(agentId) ?? true;
private int TurnLimitFor(string agentId) =>
this._autonomousTurnLimitsByAgentId.TryGetValue(agentId, out int limit) ? limit : this._autonomousTurnLimit;
private string ContinuationPromptFor(string agentId) =>
this._autonomousContinuationPromptsByAgentId.TryGetValue(agentId, out string? prompt) ? prompt : this._autonomousContinuationPrompt;
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
ProtocolBuilder pb = protocolBuilder
.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<HandoffState>(
(handoff, context, cancellationToken) => this.HandleAsync(handoff, context, cancellationToken)))
.YieldsOutput<List<ChatMessage>>();
// Only advertise the outgoing-message capability when autonomous mode is enabled, since the
// downstream return switch (Builder.AddSwitch on End) is only wired in that case.
if (this._autonomousMode)
{
pb = pb.SendsMessage<HandoffState>();
}
return pb;
}
private async ValueTask HandleAsync(HandoffState handoff, IWorkflowContext context, CancellationToken cancellationToken)
{
@@ -31,7 +89,56 @@ internal sealed class HandoffEndExecutor(bool returnToPrevious) : Executor(Execu
throw new InvalidOperationException("Handoff Orchestration shared state was not properly initialized.");
}
if (returnToPrevious)
// Autonomous mode: when the agent did not request a handoff and termination has not fired,
// loop control back to the same agent (up to that agent's turn limit). Per-agent overrides
// (enabled-agents allow-list, turn limit, continuation prompt) are honored here.
bool canContinueAutonomously = this._autonomousMode
&& !handoff.IsTerminated
&& handoff.RequestedHandoffTargetAgentId is null
&& handoff.PreviousAgentId is not null
&& this.IsAutonomousEnabledFor(handoff.PreviousAgentId!);
if (canContinueAutonomously)
{
string agentId = handoff.PreviousAgentId!;
int turns = sharedState.AutonomousTurnsByAgent.TryGetValue(agentId, out int existing) ? existing : 0;
int limit = this.TurnLimitFor(agentId);
if (turns < limit)
{
sharedState.AutonomousTurnsByAgent[agentId] = turns + 1;
// Append a synthetic user message containing the continuation prompt so the agent
// has fresh input to act on for the next autonomous iteration.
sharedState.Conversation.AddMessage(new ChatMessage(ChatRole.User, this.ContinuationPromptFor(agentId))
{
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
});
// Send a HandoffState targeting the source agent. The downstream
// HandoffAutonomousReturnSwitch routes it to the matching agent executor.
HandoffState loopBack = new(
handoff.TurnToken,
RequestedHandoffTargetAgentId: agentId,
PreviousAgentId: agentId,
IsTerminated: false);
await context.SendMessageAsync(loopBack, cancellationToken).ConfigureAwait(false);
return sharedState;
}
}
// Terminal path: either termination fired, autonomous mode is disabled, or the turn
// limit is reached. Reset this agent's autonomous counter so a subsequent user turn
// starts fresh, then yield the conversation as workflow output.
if (handoff.PreviousAgentId is not null)
{
sharedState.AutonomousTurnsByAgent[handoff.PreviousAgentId] = 0;
}
if (this._returnToPrevious)
{
sharedState.PreviousAgentId = handoff.PreviousAgentId;
}
@@ -25,21 +25,32 @@ internal static class HandoffConstants
internal sealed class HandoffSharedState
{
[JsonConstructor]
internal HandoffSharedState(MultiPartyConversation conversation, string? previousAgentId)
internal HandoffSharedState(MultiPartyConversation conversation, string? previousAgentId, Dictionary<string, int>? autonomousTurnsByAgent)
{
this.Conversation = conversation;
this.PreviousAgentId = previousAgentId;
this.AutonomousTurnsByAgent = autonomousTurnsByAgent ?? [];
}
public HandoffSharedState()
{
this.Conversation = new([]);
this.AutonomousTurnsByAgent = [];
}
[JsonInclude]
public MultiPartyConversation Conversation { get; internal set; }
public string? PreviousAgentId { get; set; }
/// <summary>
/// Tracks the number of autonomous-mode continuation iterations consumed by each agent in the current
/// "active" autonomous run. The counter is incremented by <see cref="HandoffEndExecutor"/> each time
/// the End executor loops control back to the source agent in autonomous mode, and reset to 0 once
/// the autonomous loop terminates (limit reached or termination condition fired).
/// </summary>
[JsonInclude]
public Dictionary<string, int> AutonomousTurnsByAgent { get; internal set; }
}
/// <summary>Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token.</summary>
@@ -64,6 +75,10 @@ internal sealed class HandoffStartExecutor(bool returnToPrevious) : ChatProtocol
sharedState ??= new HandoffSharedState();
sharedState.Conversation.AddMessages(messages);
// Reset all autonomous-mode counters at the start of every fresh user turn so that a
// prior turn's counters cannot prematurely terminate the new turn's autonomous loop.
sharedState.AutonomousTurnsByAgent.Clear();
string? previousAgentId = sharedState.PreviousAgentId;
// If we are configured to return to the previous agent, include the previous agent id in the handoff state.
@@ -5,4 +5,5 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
internal sealed record class HandoffState(
TurnToken TurnToken,
string? RequestedHandoffTargetAgentId,
string? PreviousAgentId = null);
string? PreviousAgentId = null,
bool IsTerminated = false);
@@ -1157,6 +1157,299 @@ public class HandoffOrchestrationTests
}
}
#region Default Handoffs Tests
[Fact]
public async Task Handoffs_DefaultHandoffs_AllAgentsCanHandOffToAllOthersAsync()
{
// Verifies "default handoffs": when no explicit WithHandoff calls are made,
// every registered participant is wired to every other participant.
var agentA = new ChatClientAgent(new MockChatClient((messages, options) =>
{
// Expect tools to include handoffs for B and C (every other agent).
var transferTools = options?.Tools?.Where(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal)).ToList();
Assert.NotNull(transferTools);
Assert.Equal(2, transferTools!.Count);
// Pick the first one to hand off (it should route to either B or C).
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferTools[0].Name)]));
}), name: "agentA", description: "agent A");
var agentB = new ChatClientAgent(new MockChatClient((messages, options) =>
new(new ChatMessage(ChatRole.Assistant, "B responded"))),
name: "agentB", description: "agent B");
var agentC = new ChatClientAgent(new MockChatClient((messages, options) =>
new(new ChatMessage(ChatRole.Assistant, "C responded"))),
name: "agentC", description: "agent C");
var workflow =
AgentWorkflowBuilder.CreateHandoffBuilderWith(agentA)
.AddParticipants(agentB, agentC)
.Build();
(string updateText, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "hi")]);
// The first response handed off — verify the second agent responded.
Assert.NotNull(result);
Assert.True(updateText is "B responded" or "C responded",
$"Expected B or C to respond, got '{updateText}'");
}
[Fact]
public async Task Handoffs_DefaultHandoffs_OnlyAppliesWhenNoExplicitHandoffsAsync()
{
// When explicit handoffs are defined, default handoffs do NOT activate — only the explicit edges apply.
var agentA = new ChatClientAgent(new MockChatClient((messages, options) =>
{
// Should only see one handoff tool (to B), not B and C.
var transferTools = options?.Tools?.Where(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal)).ToList();
Assert.NotNull(transferTools);
Assert.Single(transferTools!);
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferTools![0].Name)]));
}), name: "agentA", description: "agent A");
var agentB = new ChatClientAgent(new MockChatClient((messages, options) =>
new(new ChatMessage(ChatRole.Assistant, "B responded"))),
name: "agentB", description: "agent B");
// Only define an explicit A->B edge. Default mesh must not activate.
var workflow =
AgentWorkflowBuilder.CreateHandoffBuilderWith(agentA)
.WithHandoff(agentA, agentB)
.Build();
(string updateText, _, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "hi")]);
Assert.Equal("B responded", updateText);
}
#endregion Default Handoffs Tests
#region Autonomous Mode Tests
[Fact]
public async Task Handoffs_AutonomousMode_IteratesUntilHandoffAsync()
{
// With autonomous mode enabled, an agent that does not handoff is invoked again with the
// continuation prompt until it eventually invokes a handoff (or hits the turn limit).
int agentACallCount = 0;
const int TargetIterations = 3;
var agentA = new ChatClientAgent(new MockChatClient((messages, options) =>
{
agentACallCount++;
if (agentACallCount < TargetIterations)
{
// Respond with text only (no handoff) — should trigger autonomous continuation.
return new(new ChatMessage(ChatRole.Assistant, $"iteration {agentACallCount}"));
}
// After TargetIterations calls, hand off.
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: "agentA");
var agentB = new ChatClientAgent(new MockChatClient((messages, options) =>
new(new ChatMessage(ChatRole.Assistant, "B final"))),
name: "agentB", description: "agent B");
var workflow =
AgentWorkflowBuilder.CreateHandoffBuilderWith(agentA)
.WithHandoff(agentA, agentB)
.WithAutonomousMode()
.Build();
(string updateText, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "go"),]);
Assert.Equal(TargetIterations, agentACallCount);
Assert.NotNull(result);
Assert.Contains("B final", updateText);
// Conversation should contain the continuation prompts injected between A's responses.
Assert.Contains(result, m => m.Role == ChatRole.User && m.Text == HandoffWorkflowBuilderDefaults.DefaultAutonomousContinuationPrompt);
}
[Fact]
public async Task Handoffs_AutonomousMode_RespectsTurnLimitAsync()
{
// With a turn limit of N, the agent should be invoked initial+N times before the workflow ends
// (when the agent never invokes a handoff).
int callCount = 0;
const int TurnLimit = 2;
var agentA = new ChatClientAgent(new MockChatClient((messages, options) =>
{
callCount++;
return new(new ChatMessage(ChatRole.Assistant, $"call {callCount}"));
}), name: "agentA");
var agentB = new ChatClientAgent(new MockChatClient((messages, options) =>
{
Assert.Fail("B should never be reached since A never hands off.");
return new();
}), name: "agentB", description: "agent B");
var workflow =
AgentWorkflowBuilder.CreateHandoffBuilderWith(agentA)
.WithHandoff(agentA, agentB)
.WithAutonomousMode(turnLimit: TurnLimit)
.Build();
(_, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "go")]);
// First call + TurnLimit continuation iterations = TurnLimit + 1 invocations.
Assert.Equal(TurnLimit + 1, callCount);
Assert.NotNull(result);
}
[Fact]
public async Task Handoffs_AutonomousMode_UsesCustomContinuationPromptAsync()
{
const string CustomPrompt = "Keep going, please.";
int callCount = 0;
var agentA = new ChatClientAgent(new MockChatClient((messages, options) =>
{
callCount++;
if (callCount > 1)
{
// After first call, verify the latest user message is the custom prompt.
var lastUserMessage = messages.LastOrDefault(m => m.Role == ChatRole.User);
Assert.NotNull(lastUserMessage);
Assert.Equal(CustomPrompt, lastUserMessage!.Text);
}
return new(new ChatMessage(ChatRole.Assistant, $"call {callCount}"));
}), name: "agentA");
var agentB = new ChatClientAgent(new MockChatClient((messages, options) => new()),
name: "agentB", description: "agent B");
var workflow =
AgentWorkflowBuilder.CreateHandoffBuilderWith(agentA)
.WithHandoff(agentA, agentB)
.WithAutonomousMode(turnLimit: 2, continuationPrompt: CustomPrompt)
.Build();
(_, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "go")]);
Assert.Equal(3, callCount); // 1 initial + 2 autonomous continuations
Assert.NotNull(result);
Assert.Contains(result, m => m.Role == ChatRole.User && m.Text == CustomPrompt);
}
#endregion Autonomous Mode Tests
#region Termination Condition Tests
[Fact]
public async Task Handoffs_SyncTerminationCondition_EndsAutonomousLoopAsync()
{
int callCount = 0;
var agentA = new ChatClientAgent(new MockChatClient((messages, options) =>
{
callCount++;
return new(new ChatMessage(ChatRole.Assistant, $"response {callCount}"));
}), name: "agentA");
var agentB = new ChatClientAgent(new MockChatClient((messages, options) => new()),
name: "agentB", description: "agent B");
// Sync termination: stop as soon as conversation contains a message with text "response 2".
var workflow =
AgentWorkflowBuilder.CreateHandoffBuilderWith(agentA)
.WithHandoff(agentA, agentB)
.WithAutonomousMode(turnLimit: 10)
.WithTerminationCondition(conversation => conversation.Any(m => m.Text == "response 2"))
.Build();
(_, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "go")]);
// Agent should be invoked twice: once initially, once after the autonomous continuation,
// at which point the termination condition fires and the loop ends.
Assert.Equal(2, callCount);
Assert.NotNull(result);
}
[Fact]
public async Task Handoffs_AsyncTerminationCondition_EndsAutonomousLoopAsync()
{
int callCount = 0;
var agentA = new ChatClientAgent(new MockChatClient((messages, options) =>
{
callCount++;
return new(new ChatMessage(ChatRole.Assistant, $"response {callCount}"));
}), name: "agentA");
var agentB = new ChatClientAgent(new MockChatClient((messages, options) => new()),
name: "agentB", description: "agent B");
// Async termination: same effect, but exercises the async overload.
var workflow =
AgentWorkflowBuilder.CreateHandoffBuilderWith(agentA)
.WithHandoff(agentA, agentB)
.WithAutonomousMode(turnLimit: 10)
.WithTerminationCondition(async conversation =>
{
await Task.Yield();
return conversation.Any(m => m.Text == "response 3");
})
.Build();
(_, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "go")]);
Assert.Equal(3, callCount);
Assert.NotNull(result);
}
[Fact]
public async Task Handoffs_TerminationCondition_NotInvokedOnHandoffAsync()
{
// The termination condition is only evaluated when the agent did not request a handoff.
// Verify a handoff occurs without consulting the predicate.
bool predicateInvoked = false;
var agentA = new ChatClientAgent(new MockChatClient((messages, options) =>
{
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: "agentA");
var agentB = new ChatClientAgent(new MockChatClient((messages, options) =>
new(new ChatMessage(ChatRole.Assistant, "B done"))),
name: "agentB", description: "agent B");
var workflow =
AgentWorkflowBuilder.CreateHandoffBuilderWith(agentA)
.WithHandoff(agentA, agentB)
.WithTerminationCondition(_ =>
{
predicateInvoked = true;
return true;
})
.Build();
(string updateText, _, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "go")]);
// Only B's response should have ended the workflow; predicate evaluated on B (no further handoff).
Assert.Equal("B done", updateText);
Assert.True(predicateInvoked, "Predicate should have been invoked at least once (on the terminating agent).");
}
#endregion Termination Condition Tests
#region Helper Types and Methods
private sealed record WorkflowRunResult(string UpdateText, List<ChatMessage>? Result, CheckpointInfo? LastCheckpoint, List<RequestInfoEvent> PendingRequests);