mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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:
committed by
GitHub
Unverified
parent
401a552735
commit
945647a065
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user