Clean up handoff orchestration creation (#235)

- Remove Dictionary-derived types
- Add an optional name to orchestrations
- Make Handoffs based purely on AIAgent instances rather than separately provided names
This commit is contained in:
Stephen Toub
2025-07-24 12:38:23 -04:00
committed by GitHub
Unverified
parent a72245287f
commit 233c557173
13 changed files with 780 additions and 354 deletions
@@ -56,11 +56,12 @@ public class HandoffOrchestration_Intro(ITestOutputHelper output) : Orchestratio
responses.Enqueue("Order ID 321");
responses.Enqueue("Broken item");
responses.Enqueue("No, bye");
// Define the orchestration
HandoffOrchestration orchestration =
new(OrchestrationHandoffs
new(Handoffs
.StartWith(triageAgent)
.Add(triageAgent, statusAgent, returnAgent, refundAgent)
.Add(triageAgent, [statusAgent, returnAgent, refundAgent])
.Add(statusAgent, triageAgent, "Transfer to this agent if the issue is not status related")
.Add(returnAgent, triageAgent, "Transfer to this agent if the issue is not return related")
.Add(refundAgent, triageAgent, "Transfer to this agent if the issue is not refund related"))
@@ -46,9 +46,9 @@ public class HandoffOrchestration_With_StructuredInput(ITestOutputHelper output)
// Define the orchestration
HandoffOrchestration orchestration =
new(OrchestrationHandoffs
new(Handoffs
.StartWith(triageAgent)
.Add(triageAgent, dotnetAgent, pythonAgent))
.Add(triageAgent, [dotnetAgent, pythonAgent]))
{
LoggerFactory = this.LoggerFactory,
ResponseCallback = monitor.ResponseCallback,
@@ -20,10 +20,11 @@ public static class HostApplicationBuilderAgentExtensions
ChatClientAgent target = new(chatClient, instructions, $"{name}_targetAgent");
ChatClientAgent customerService = new(chatClient, "You are a customer service agent. You will handle rude, angry, or upset customer inquiries, asking them to be more calm and polite.", $"{name}_customerServiceAgent");
return new HandoffOrchestration(OrchestrationHandoffs
return Handoffs
.StartWith(triage)
.Add(triage, target, "Hand off to the target agent for handling normal customer requests.")
.Add(triage, customerService, "Hand off to the customer service agent for handling rude customer inquiries."));
.Add(triage, customerService, "Hand off to the customer service agent for handling rude customer inquiries.")
.Build("PirateWorkflow");
});
var actorBuilder = builder.AddActorRuntime();
@@ -19,7 +19,14 @@ public partial class ConcurrentOrchestration : OrchestratingAgent
/// <summary>Initializes a new instance of the <see cref="ConcurrentOrchestration"/> class.</summary>
/// <param name="subagents">The agents participating in the orchestration.</param>
public ConcurrentOrchestration(params AIAgent[] subagents) : base(subagents)
public ConcurrentOrchestration(params AIAgent[] subagents) : this(subagents, name: null)
{
}
/// <summary>Initializes a new instance of the <see cref="ConcurrentOrchestration"/> class.</summary>
/// <param name="subagents">The agents participating in the orchestration.</param>
/// <param name="name">An optional name for this orchestrating agent.</param>
public ConcurrentOrchestration(AIAgent[] subagents, string? name) : base(subagents, name)
{
}
@@ -86,11 +86,9 @@ public abstract class GroupChatManager
/// <returns>A <see cref="GroupChatManagerResult{TValue}"/> indicating whether the chat should be terminated.</returns>
protected internal virtual ValueTask<GroupChatManagerResult<bool>> ShouldTerminate(IReadOnlyCollection<ChatMessage> history, CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref this._invocationCount);
bool resultValue = false;
string reason = "Maximum number of invocations has not been reached.";
if (this.InvocationCount > this.MaximumInvocationCount)
if (Interlocked.Increment(ref this._invocationCount) > this.MaximumInvocationCount)
{
resultValue = true;
reason = "Maximum number of invocations reached.";
@@ -24,7 +24,17 @@ public sealed partial class GroupChatOrchestration : OrchestratingAgent
/// </summary>
/// <param name="manager">The manager that controls the flow of the group-chat.</param>
/// <param name="agents">The agents participating in the orchestration.</param>
public GroupChatOrchestration(GroupChatManager manager, params AIAgent[] agents) : base(agents)
public GroupChatOrchestration(GroupChatManager manager, params AIAgent[] agents) : this(manager, agents, name: null)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="GroupChatOrchestration"/> class.
/// </summary>
/// <param name="manager">The manager that controls the flow of the group-chat.</param>
/// <param name="agents">The agents participating in the orchestration.</param>
/// <param name="name">An optional name for this orchestrating agent.</param>
public GroupChatOrchestration(GroupChatManager manager, AIAgent[] agents, string? name) : base(agents, name)
{
this._manager = Throw.IfNull(manager);
}
@@ -19,14 +19,16 @@ public class RoundRobinGroupChatManager : GroupChatManager
private int _currentAgentIndex;
/// <inheritdoc/>
protected internal override ValueTask<GroupChatManagerResult<string>> FilterResults(IReadOnlyCollection<ChatMessage> history, CancellationToken cancellationToken = default)
protected internal override ValueTask<GroupChatManagerResult<string>> FilterResults(
IReadOnlyCollection<ChatMessage> history, CancellationToken cancellationToken = default)
{
GroupChatManagerResult<string> result = new(history.LastOrDefault()?.Text ?? string.Empty) { Reason = "Default result filter provides the final chat message." };
return new ValueTask<GroupChatManagerResult<string>>(result);
}
/// <inheritdoc/>
protected internal override ValueTask<GroupChatManagerResult<string>> SelectNextAgent(IReadOnlyCollection<ChatMessage> history, GroupChatTeam team, CancellationToken cancellationToken = default)
protected internal override ValueTask<GroupChatManagerResult<string>> SelectNextAgent(
IReadOnlyCollection<ChatMessage> history, GroupChatTeam team, CancellationToken cancellationToken = default)
{
string nextAgent = team.Skip(this._currentAgentIndex).First().Key;
this._currentAgentIndex = (this._currentAgentIndex + 1) % team.Count;
@@ -35,7 +37,8 @@ public class RoundRobinGroupChatManager : GroupChatManager
}
/// <inheritdoc/>
protected internal override ValueTask<GroupChatManagerResult<bool>> ShouldRequestUserInput(IReadOnlyCollection<ChatMessage> history, CancellationToken cancellationToken = default)
protected internal override ValueTask<GroupChatManagerResult<bool>> ShouldRequestUserInput(
IReadOnlyCollection<ChatMessage> history, CancellationToken cancellationToken = default)
{
GroupChatManagerResult<bool> result = new(false) { Reason = "The default round-robin group chat manager does not request user input." };
return new ValueTask<GroupChatManagerResult<bool>>(result);
@@ -20,29 +20,23 @@ namespace Microsoft.Agents.Orchestration;
/// </summary>
public sealed partial class HandoffOrchestration : OrchestratingAgent
{
private readonly OrchestrationHandoffs _handoffs;
private readonly Handoffs _handoffs;
/// <summary>
/// Initializes a new instance of the <see cref="HandoffOrchestration"/> class.
/// </summary>
/// <param name="handoffs">Defines the handoff connections for each agent.</param>
/// <param name="agents">Additional agents participating in the orchestration that weren't passed to <paramref name="handoffs"/>.</param>
public HandoffOrchestration(OrchestrationHandoffs handoffs, params AIAgent[] agents) : base(
agents is { Length: 0 } ? [.. handoffs.Agents] :
handoffs.Agents is { Count: 0 } ? agents :
[.. handoffs.Agents.Concat(agents).Distinct()])
public HandoffOrchestration(Handoffs handoffs) : this(handoffs, name: null)
{
// Create list of distinct agent names
HashSet<string> agentNames = [.. base.Agents.Select(a => a.DisplayName), handoffs.FirstAgentName];
// Extract names from handoffs that don't align with a member agent.
// Fail fast if invalid names are present.
string[] badNames = [.. handoffs.Keys.Concat(handoffs.Values.SelectMany(h => h.Keys)).Where(name => !agentNames.Contains(name))];
if (badNames.Length > 0)
{
Throw.ArgumentException(nameof(handoffs), $"The following agents are not defined in the orchestration: {string.Join(", ", badNames)}");
}
}
/// <summary>
/// Initializes a new instance of the <see cref="HandoffOrchestration"/> class.
/// </summary>
/// <param name="handoffs">Defines the handoff connections for each agent.</param>
/// <param name="name">An optional name for this orchestrating agent.</param>
public HandoffOrchestration(Handoffs handoffs, string? name) : base(handoffs.Agents.ToArray(), name)
{
this._handoffs = handoffs;
}
@@ -54,39 +48,51 @@ public sealed partial class HandoffOrchestration : OrchestratingAgent
{
List<ChatMessage> allMessages = [.. messages];
int originalMessageCount = allMessages.Count;
return this.ResumeAsync(this._handoffs.FirstAgentName, allMessages, originalMessageCount, context, cancellationToken);
return this.ResumeAsync(this._handoffs.InitialAgent, allMessages, originalMessageCount, context, cancellationToken);
}
/// <inheritdoc />
protected override Task<AgentRunResponse> ResumeCoreAsync(JsonElement checkpointState, OrchestratingAgentContext context, CancellationToken cancellationToken)
{
var state = checkpointState.Deserialize(OrchestrationJsonContext.Default.HandoffState) ?? throw new InvalidOperationException("The checkpoint state is invalid.");
return this.ResumeAsync(state.NextAgent, state.AllMessages, state.OriginalMessageCount, context, cancellationToken);
AIAgent? nextAgent = null;
foreach (var agent in this.Agents)
{
if (agent.Id == state.NextAgent)
{
nextAgent = agent;
break;
}
}
if (nextAgent is null)
{
Throw.InvalidOperationException($"The next agent '{state.NextAgent}' is not defined in the orchestration.");
}
return this.ResumeAsync(nextAgent, state.AllMessages, state.OriginalMessageCount, context, cancellationToken);
}
/// <inheritdoc />
private async Task<AgentRunResponse> ResumeAsync(
string? nextAgent, List<ChatMessage> allMessages, int originalMessageCount, OrchestratingAgentContext context, CancellationToken cancellationToken)
AIAgent? agent, List<ChatMessage> allMessages, int originalMessageCount, OrchestratingAgentContext context, CancellationToken cancellationToken)
{
Debug.Assert(nextAgent is not null);
Debug.Assert(agent is not null);
AgentRunResponse? response = null;
while (nextAgent is not null)
while (agent is not null)
{
AIAgent? agent =
this.Agents.FirstOrDefault(a => a.Name == nextAgent || a.Id == nextAgent) ??
throw new InvalidOperationException($"The agent '{nextAgent}' is not defined in the orchestration.");
this.LogOrchestrationSubagentRunning(context, agent);
if (!this._handoffs.TryGetValue(agent.DisplayName, out AgentHandoffs? handoffs) || handoffs.Count == 0)
if (!this._handoffs.Targets.TryGetValue(agent, out var handoffs) || handoffs.Count == 0)
{
// If no handoff is available, we can run the agent directly and return its response.
response = await RunAsync(agent, context, allMessages, context.Options, cancellationToken).ConfigureAwait(false);
allMessages.AddRange(response.Messages);
nextAgent = null;
await CheckpointAsync().ConfigureAwait(false);
this.LogOrchestrationSubagentCompleted(context, agent);
allMessages.AddRange(response.Messages);
agent = null;
await CheckpointAsync().ConfigureAwait(false);
break;
}
@@ -107,8 +113,9 @@ public sealed partial class HandoffOrchestration : OrchestratingAgent
// Invoke the next agent with all of the messages collected so far.
response = await RunAsync(agent, context, allMessages, options, cancellationToken).ConfigureAwait(false);
this.LogOrchestrationSubagentCompleted(context, agent);
allMessages.AddRange(response.Messages);
nextAgent = handoffCtx.TargetedAgent;
agent = handoffCtx.TargetedAgent;
RemoveHandoffFunctionCalls(response, handoffTools);
if (this.InteractiveCallback is not null)
@@ -118,12 +125,10 @@ public sealed partial class HandoffOrchestration : OrchestratingAgent
break;
}
nextAgent = agent.DisplayName;
allMessages.Add(await this.InteractiveCallback().ConfigureAwait(false));
}
await CheckpointAsync().ConfigureAwait(false);
this.LogOrchestrationSubagentCompleted(context, agent);
}
allMessages.RemoveRange(0, originalMessageCount);
@@ -132,7 +137,7 @@ public sealed partial class HandoffOrchestration : OrchestratingAgent
return response;
Task CheckpointAsync() => context.Runtime is not null ?
base.WriteCheckpointAsync(JsonSerializer.SerializeToElement(new(nextAgent, allMessages, originalMessageCount), OrchestrationJsonContext.Default.HandoffState), context, cancellationToken) :
base.WriteCheckpointAsync(JsonSerializer.SerializeToElement(new(agent?.Id, allMessages, originalMessageCount), OrchestrationJsonContext.Default.HandoffState), context, cancellationToken) :
Task.CompletedTask;
}
@@ -173,9 +178,9 @@ public sealed partial class HandoffOrchestration : OrchestratingAgent
}
}
private sealed class HandoffContext(AgentHandoffs handoffs)
private sealed class HandoffContext(HashSet<Handoffs.HandoffTarget> handoffs)
{
public string? TargetedAgent { get; set; }
public AIAgent? TargetedAgent { get; set; }
public bool EndTaskInvoked { get; set; }
public List<AITool> CreateHandoffFunctions(bool needsEndTask)
@@ -194,16 +199,16 @@ public sealed partial class HandoffOrchestration : OrchestratingAgent
description: "Invoke this function when all work is completed and no further interactions are required."));
}
foreach (KeyValuePair<string, string> handoff in handoffs)
foreach (Handoffs.HandoffTarget handoff in handoffs)
{
functions.Add(AIFunctionFactory.Create(
() =>
{
this.TargetedAgent = handoff.Key;
this.TargetedAgent = handoff.Target;
Terminate();
},
name: $"handoff_to_{InvalidNameCharsRegex().Replace(handoff.Key, "_")}",
description: handoff.Value));
name: $"handoff_to_{InvalidNameCharsRegex().Replace(handoff.Target.DisplayName, "_")}",
description: handoff.Reason));
}
return functions;
@@ -1,155 +1,187 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections;
using System.Collections.Generic;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Extensions.AI.Agents.Runtime;
using Microsoft.Shared.Diagnostics;
#pragma warning disable CA1710 // Identifiers should have correct suffix
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// Defines the handoff relationships for a given agent.
/// Maps target agent names/IDs to handoff descriptions.
/// </summary>
public sealed class AgentHandoffs : Dictionary<string, string>
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentHandoffs"/> class with no handoff relationships.
/// </summary>
public AgentHandoffs() { }
/// <summary>
/// Initializes a new instance of the <see cref="AgentHandoffs"/> class with the specified handoff relationships.
/// </summary>
/// <param name="handoffs">A dictionary mapping target agent names/IDs to handoff descriptions.</param>
public AgentHandoffs(Dictionary<string, string> handoffs) : base(handoffs) { }
}
/// <summary>
/// Defines the orchestration handoff relationships for all agents in the system.
/// Maps source agent names/IDs to their <see cref="AgentHandoffs"/>.
/// </summary>
public sealed class OrchestrationHandoffs : Dictionary<string, AgentHandoffs>
public sealed class Handoffs :
IReadOnlyDictionary<AIAgent, IEnumerable<Handoffs.HandoffTarget>>
{
/// <summary>
/// Initializes a new instance of the <see cref="OrchestrationHandoffs"/> class with no handoff relationships.
/// Initializes a new instance of the <see cref="Orchestration.Handoffs"/> class with no handoff relationships.
/// </summary>
/// <param name="firstAgent">The first agent to be invoked (prior to any handoff).</param>
public OrchestrationHandoffs(AIAgent firstAgent)
: this(firstAgent.DisplayName)
/// <param name="initialAgent">The first agent to be invoked (prior to any handoff).</param>
private Handoffs(AIAgent initialAgent)
{
this.Agents.Add(firstAgent);
Throw.IfNull(initialAgent);
this.Agents.Add(initialAgent);
this.InitialAgent = initialAgent;
}
/// <summary>
/// Initializes a new instance of the <see cref="OrchestrationHandoffs"/> class with no handoff relationships.
/// </summary>
/// <param name="firstAgentName">The name of the first agent to be invoked (prior to any handoff).</param>
public OrchestrationHandoffs(string firstAgentName)
{
Throw.IfNullOrWhitespace(firstAgentName, nameof(firstAgentName));
this.FirstAgentName = firstAgentName;
}
/// <summary>Gets the initial agent to which the first messages will be sent.</summary>
public AIAgent InitialAgent { get; }
/// <summary>Gets a collection of all handoff targets, indexed by the source of the handoffs.</summary>
internal Dictionary<AIAgent, HashSet<HandoffTarget>> Targets { get; } = [];
/// <summary>Gets a set of all agents involved in the handoffs, sources and targets.</summary>
internal HashSet<AIAgent> Agents { get; } = [];
/// <summary>
/// The name of the first agent to be invoked (prior to any handoff).
/// Creates a new collection of handoffs that start with the specified agent.
/// </summary>
public string FirstAgentName { get; }
/// <param name="initialAgent">The initial agent.</param>
/// <returns>The new <see cref="Orchestration.Handoffs"/> instance.</returns>
public static Handoffs StartWith(AIAgent initialAgent) => new(initialAgent);
/// <summary>Creates a new <see cref="HandoffOrchestration"/> from the described handoffs.</summary>
/// <param name="name">An optional name for this orchestrating agent.</param>
/// <returns>The new <see cref="HandoffOrchestration"/>.</returns>
public HandoffOrchestration Build(string? name = null) => new(this, name);
/// <summary>
/// Adds handoff relationships from a source agent to one or more target agents.
/// Each target agent's name or ID is mapped to its description.
/// </summary>
/// <param name="source">The source agent.</param>
/// <returns>The updated <see cref="OrchestrationHandoffs"/> instance.</returns>
public static OrchestrationHandoffs StartWith(AIAgent source) => new(source);
/// <summary>
/// Adds handoff relationships from a source agent to one or more target agents.
/// Each target agent's name or ID is mapped to its description.
/// </summary>
/// <param name="source">The source agent.</param>
/// <param name="targets">The target agents to add as handoff targets for the source agent.</param>
/// <returns>The updated <see cref="OrchestrationHandoffs"/> instance.</returns>
public OrchestrationHandoffs Add(AIAgent source, params AIAgent[] targets)
/// <returns>The updated <see cref="Orchestration.Handoffs"/> instance.</returns>
/// <remarks>The handoff reason for each target is derived from its description or name.</remarks>
public Handoffs Add(AIAgent source, AIAgent[] targets)
{
string key = source.DisplayName;
AgentHandoffs agentHandoffs = this.GetAgentHandoffs(key);
foreach (AIAgent target in targets)
Throw.IfNull(source);
Throw.IfNull(targets);
if (Array.IndexOf(targets, null) >= 0)
{
if (string.IsNullOrWhiteSpace(target.Description) && string.IsNullOrWhiteSpace(target.Name))
{
Throw.InvalidOperationException($"The provided target agent with Id '{target.Id}' has no description or name, and no handoff description has been provided. At least one of these are required to register a handoff so that the appropriate target agent can be chosen.");
}
this.Agents.Add(target);
agentHandoffs[target.DisplayName] = target.Description ?? target.Name!;
Throw.ArgumentNullException(nameof(targets), "One or more target agents are null.");
}
this.Agents.Add(source);
foreach (var target in targets)
{
this.Add(source, target);
}
return this;
}
/// <summary>
/// Adds a handoff relationship from a source agent to a target agent with a custom description.
/// Adds a handoff relationship from a source agent to a target agent with a custom handoff reason.
/// </summary>
/// <param name="source">The source agent.</param>
/// <param name="target">The target agent.</param>
/// <param name="description">The handoff description.</param>
/// <returns>The updated <see cref="OrchestrationHandoffs"/> instance.</returns>
public OrchestrationHandoffs Add(AIAgent source, AIAgent target, string description)
/// <param name="handoffReason">The reason the <paramref name="source"/> should hand off to the <paramref name="target"/>.</param>
/// <returns>The updated <see cref="Orchestration.Handoffs"/> instance.</returns>
public Handoffs Add(AIAgent source, AIAgent target, string? handoffReason = null)
{
Throw.IfNull(source);
Throw.IfNull(target);
this.Agents.Add(source);
this.Agents.Add(target);
return this.Add(source.DisplayName, target.DisplayName, description);
}
/// <summary>
/// Adds a handoff relationship from a source agent to a target agent name/ID with a custom description.
/// </summary>
/// <param name="source">The source agent.</param>
/// <param name="targetName">The target agent's name or ID.</param>
/// <param name="description">The handoff description.</param>
/// <returns>The updated <see cref="OrchestrationHandoffs"/> instance.</returns>
public OrchestrationHandoffs Add(AIAgent source, string targetName, string description)
{
this.Agents.Add(source);
return this.Add(source.DisplayName, targetName, description);
}
if (!this.Targets.TryGetValue(source, out var handoffs))
{
this.Targets[source] = handoffs = [];
}
/// <summary>
/// Adds a handoff relationship from a source agent name/ID to a target agent name/ID with a custom description.
/// </summary>
/// <param name="sourceName">The source agent's name or ID.</param>
/// <param name="targetName">The target agent's name or ID.</param>
/// <param name="description">The handoff description.</param>
/// <returns>The updated <see cref="OrchestrationHandoffs"/> instance.</returns>
public OrchestrationHandoffs Add(string sourceName, string targetName, string description)
{
AgentHandoffs agentHandoffs = this.GetAgentHandoffs(sourceName);
agentHandoffs[targetName] = description;
if (!handoffs.Add(new(target, handoffReason)))
{
Throw.InvalidOperationException($"A handoff from agent '{source.DisplayName}' to agent '{target.DisplayName}' has already been registered.");
}
return this;
}
private AgentHandoffs GetAgentHandoffs(string key)
{
if (!this.TryGetValue(key, out AgentHandoffs? agentHandoffs))
{
this[key] = agentHandoffs = [];
}
/// <inheritdoc />
IEnumerable<HandoffTarget> IReadOnlyDictionary<AIAgent, IEnumerable<HandoffTarget>>.this[AIAgent key] => this.Targets[key];
return agentHandoffs;
/// <inheritdoc />
IEnumerable<AIAgent> IReadOnlyDictionary<AIAgent, IEnumerable<HandoffTarget>>.Keys => this.Targets.Keys;
/// <inheritdoc />
IEnumerable<IEnumerable<HandoffTarget>> IReadOnlyDictionary<AIAgent, IEnumerable<HandoffTarget>>.Values => this.Targets.Values;
/// <inheritdoc />
int IReadOnlyCollection<KeyValuePair<AIAgent, IEnumerable<HandoffTarget>>>.Count => this.Targets.Count;
/// <inheritdoc />
bool IReadOnlyDictionary<AIAgent, IEnumerable<HandoffTarget>>.ContainsKey(AIAgent key) => this.Targets.ContainsKey(key);
/// <inheritdoc />
IEnumerator<KeyValuePair<AIAgent, IEnumerable<HandoffTarget>>> IEnumerable<KeyValuePair<AIAgent, IEnumerable<HandoffTarget>>>.GetEnumerator()
{
foreach (var kvp in this.Targets)
{
yield return new(kvp.Key, kvp.Value);
}
}
internal HashSet<AIAgent> Agents { get; } = [];
}
/// <inheritdoc />
IEnumerator IEnumerable.GetEnumerator() =>
((IReadOnlyDictionary<AIAgent, IEnumerable<Handoffs.HandoffTarget>>)this).GetEnumerator();
/// <summary>
/// Handoff relationships post-processed into a name-based lookup table that includes the agent type and handoff description.
/// Maps agent names/IDs to a tuple of <see cref="ActorType"/> and handoff description.
/// </summary>
internal sealed class HandoffLookup : Dictionary<string, (ActorType AgentType, string Description)>;
/// <inheritdoc />
bool IReadOnlyDictionary<AIAgent, IEnumerable<HandoffTarget>>.TryGetValue(AIAgent key, out IEnumerable<HandoffTarget> value)
{
if (this.Targets.TryGetValue(key, out var handoffs))
{
value = handoffs;
return true;
}
value = [];
return false;
}
/// <summary>Describes a handoff to a specific target <see cref="AIAgent"/>.</summary>
public readonly struct HandoffTarget : IEquatable<HandoffTarget>
{
internal HandoffTarget(AIAgent target, string? reason = null)
{
this.Target = Throw.IfNull(target);
if (string.IsNullOrWhiteSpace(reason))
{
reason = target.Description ?? target.Name;
if (string.IsNullOrWhiteSpace(reason))
{
Throw.InvalidOperationException(
$"The provided target agent with Id '{target.Id}' has no description or name, and no handoff description has been provided. " +
"At least one of these are required to register a handoff so that the appropriate target agent can be chosen.");
}
}
this.Reason = reason!;
}
/// <summary>Gets the target <see cref="AIAgent"/> of the handoff.</summary>
public AIAgent Target { get; }
/// <summary>Gets the reason a handoff to <see cref="Target"/> should be performed.</summary>
public string Reason { get; }
/// <inheritdoc />
public bool Equals(HandoffTarget other) => this.Target == other.Target;
/// <inheritdoc />
public override bool Equals(object? obj) => obj is HandoffTarget other && this.Equals(other);
/// <inheritdoc />
public override int GetHashCode() => this.Target.GetHashCode();
/// <inheritdoc />
public static bool operator ==(HandoffTarget left, HandoffTarget right) => left.Equals(right);
/// <inheritdoc />
public static bool operator !=(HandoffTarget left, HandoffTarget right) => !left.Equals(right);
}
}
@@ -27,13 +27,18 @@ public abstract partial class OrchestratingAgent : AIAgent
/// Initializes a new instance of the <see cref="OrchestratingAgent"/> class.
/// </summary>
/// <param name="agents">Specifies the agents participating in this orchestration.</param>
protected OrchestratingAgent(IReadOnlyList<AIAgent> agents)
/// <param name="name">An optional name for this agent.</param>
protected OrchestratingAgent(IReadOnlyList<AIAgent> agents, string? name = null)
{
_ = Throw.IfNullOrEmpty(agents);
this.Agents = agents;
this.Name = name;
}
/// <inheritdoc />
public override string? Name { get; }
/// <summary>
/// Gets the list of member targets involved in the orchestration.
/// </summary>
@@ -16,7 +16,14 @@ public sealed partial class SequentialOrchestration : OrchestratingAgent
{
/// <summary>Initializes a new instance of the <see cref="SequentialOrchestration"/> class.</summary>
/// <param name="agents">The agents participating in the orchestration.</param>
public SequentialOrchestration(params AIAgent[] agents) : base(agents)
public SequentialOrchestration(params AIAgent[] agents) : this(agents, name: null)
{
}
/// <summary>Initializes a new instance of the <see cref="SequentialOrchestration"/> class.</summary>
/// <param name="agents">The agents participating in the orchestration.</param>
/// <param name="name">An optional name for this orchestrating agent.</param>
public SequentialOrchestration(AIAgent[] agents, string? name) : base(agents, name)
{
}
@@ -49,7 +49,7 @@ public sealed class HandoffOrchestrationTests : IDisposable
Responses.Message("Final response"));
// Act: Create and execute the orchestration
string response = await ExecuteOrchestrationAsync(OrchestrationHandoffs.StartWith(mockAgent1));
string response = await ExecuteOrchestrationAsync(Handoffs.StartWith(mockAgent1));
// Assert
Assert.Equal("Final response", response);
@@ -77,15 +77,15 @@ public sealed class HandoffOrchestrationTests : IDisposable
// Act: Create and execute the orchestration
string response = await ExecuteOrchestrationAsync(
OrchestrationHandoffs
Handoffs
.StartWith(mockAgent1)
.Add(mockAgent1, mockAgent2, mockAgent3));
.Add(mockAgent1, [mockAgent2, mockAgent3]));
// Assert
Assert.Equal("Final response", response);
}
private static async Task<string> ExecuteOrchestrationAsync(OrchestrationHandoffs handoffs)
private static async Task<string> ExecuteOrchestrationAsync(Handoffs handoffs)
{
// Arrange
HandoffOrchestration orchestration = new(handoffs);
@@ -1,6 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Moq;
@@ -10,231 +13,585 @@ namespace Microsoft.Agents.Orchestration.UnitTest;
public class HandoffsTests
{
[Fact]
public void EmptyConstructorsCreateEmptyCollections()
{
AgentHandoffs agentHandoffs = [];
Assert.Empty(agentHandoffs);
OrchestrationHandoffs orchestrationHandoffs = new("first");
Assert.Empty(orchestrationHandoffs);
Assert.Equal("first", orchestrationHandoffs.FirstAgentName);
}
[Fact]
public void DictionaryConstructorsInvalidFirstAgent()
{
Assert.Throws<ArgumentNullException>(() => new OrchestrationHandoffs((string)null!));
Assert.Throws<ArgumentException>(() => new OrchestrationHandoffs(string.Empty));
Assert.Throws<ArgumentException>(() => new OrchestrationHandoffs(" "));
}
[Fact]
public void AddWithAgentObjectsCreatesHandoffRelationships()
public void StartWith_ValidAgent_ReturnsHandoffsInstance()
{
// Arrange
OrchestrationHandoffs handoffs = new("source");
AIAgent sourceAgent = CreateAgent("source", "Source Agent");
AIAgent targetAgent1 = CreateAgent("target1", "Target Agent 1");
AIAgent targetAgent2 = CreateAgent("target2", "Target Agent 2");
var agent = CreateAgent("agent1", "Test agent");
// Act
handoffs.Add(sourceAgent, targetAgent1, targetAgent2);
// Assert
Assert.Single(handoffs);
Assert.Equal("source", handoffs.FirstAgentName);
Assert.True(handoffs.ContainsKey("source"));
AgentHandoffs sourceHandoffs = handoffs["source"];
Assert.Equal(2, sourceHandoffs.Count);
Assert.Equal("Target Agent 1", sourceHandoffs["target1"]);
Assert.Equal("Target Agent 2", sourceHandoffs["target2"]);
}
[Fact]
public void AddWithAgentAndCustomDescriptionUsesCustomDescription()
{
// Arrange
OrchestrationHandoffs handoffs = new("source");
AIAgent sourceAgent = CreateAgent("source", "Source Agent");
AIAgent targetAgent = CreateAgent("target", "Target Agent");
string customDescription = "Custom handoff description";
// Act
handoffs.Add(sourceAgent, targetAgent, customDescription);
// Assert
Assert.Single(handoffs);
Assert.Equal("source", handoffs.FirstAgentName);
AgentHandoffs sourceHandoffs = handoffs["source"];
Assert.Single(sourceHandoffs);
Assert.Equal(customDescription, sourceHandoffs["target"]);
}
[Fact]
public void AddWithAgentAndTargetNameAddsHandoffWithDescription()
{
// Arrange
OrchestrationHandoffs handoffs = new("source");
AIAgent sourceAgent = CreateAgent("source", "Source Agent");
string targetName = "targetName";
string description = "Target description";
// Act
handoffs.Add(sourceAgent, targetName, description);
// Assert
Assert.Single(handoffs);
Assert.Equal("source", handoffs.FirstAgentName);
AgentHandoffs sourceHandoffs = handoffs["source"];
Assert.Single(sourceHandoffs);
Assert.Equal(description, sourceHandoffs[targetName]);
}
[Fact]
public void AddWithSourceNameAndTargetNameAddsHandoffWithDescription()
{
// Arrange
OrchestrationHandoffs handoffs = new("sourceName");
string sourceName = "sourceName";
string targetName = "targetName";
string description = "Target description";
// Act
handoffs.Add(sourceName, targetName, description);
// Assert
Assert.Single(handoffs);
Assert.Equal("sourceName", handoffs.FirstAgentName);
AgentHandoffs sourceHandoffs = handoffs[sourceName];
Assert.Single(sourceHandoffs);
Assert.Equal(description, sourceHandoffs[targetName]);
}
[Fact]
public void AddWithMultipleSourcesAndTargetsCreatesCorrectStructure()
{
// Arrange
OrchestrationHandoffs handoffs = new("source1");
AIAgent source1 = CreateAgent("source1", "Source Agent 1");
AIAgent source2 = CreateAgent("source2", "Source Agent 2");
AIAgent target1 = CreateAgent("target1", "Target Agent 1");
AIAgent target2 = CreateAgent("target2", "Target Agent 2");
AIAgent target3 = CreateAgent("target3", "Target Agent 3");
// Act
handoffs.Add(source1, target1, target2);
handoffs.Add(source2, target2, target3);
handoffs.Add(source1, target3, "Custom description");
// Assert
Assert.Equal(2, handoffs.Count);
Assert.Equal("source1", handoffs.FirstAgentName);
// Check source1's targets
AgentHandoffs source1Handoffs = handoffs["source1"];
Assert.Equal(3, source1Handoffs.Count);
Assert.Equal("Target Agent 1", source1Handoffs["target1"]);
Assert.Equal("Target Agent 2", source1Handoffs["target2"]);
Assert.Equal("Custom description", source1Handoffs["target3"]);
// Check source2's targets
AgentHandoffs source2Handoffs = handoffs["source2"];
Assert.Equal(2, source2Handoffs.Count);
Assert.Equal("Target Agent 2", source2Handoffs["target2"]);
Assert.Equal("Target Agent 3", source2Handoffs["target3"]);
}
[Fact]
public void StaticAddCreatesNewOrchestrationHandoffs()
{
// Arrange
AIAgent source = CreateAgent("source", "Source Agent");
AIAgent target1 = CreateAgent("target1", "Target Agent 1");
AIAgent target2 = CreateAgent("target2", "Target Agent 2");
// Act
OrchestrationHandoffs handoffs =
OrchestrationHandoffs
.StartWith(source)
.Add(source, target1, target2);
var handoffs = Handoffs.StartWith(agent);
// Assert
Assert.NotNull(handoffs);
Assert.Equal(source.Id, handoffs.FirstAgentName);
Assert.Single(handoffs);
Assert.True(handoffs.ContainsKey("source"));
AgentHandoffs sourceHandoffs = handoffs["source"];
Assert.Equal(2, sourceHandoffs.Count);
Assert.Equal("Target Agent 1", sourceHandoffs["target1"]);
Assert.Equal("Target Agent 2", sourceHandoffs["target2"]);
Assert.Equal(agent, handoffs.InitialAgent);
Assert.Contains(agent, handoffs.Agents);
Assert.Empty(handoffs.Targets);
}
[Fact]
public void AddWithAgentsWithNoNameUsesId()
public void StartWith_NullAgent_ThrowsArgumentNullException()
{
// Act & Assert
Assert.Throws<ArgumentNullException>("initialAgent", () => Handoffs.StartWith(null!));
}
[Fact]
public void Add_ValidSourceAndTargets_AddsHandoffRelationships()
{
// Arrange
OrchestrationHandoffs handoffs = new("source-id");
AIAgent sourceAgent = CreateAgent(id: "source-id", name: null);
AIAgent targetAgent = CreateAgent(id: "target-id", name: null, description: "Target Description");
var sourceAgent = CreateAgent("source", "Source agent");
var targetAgent1 = CreateAgent("target1", "Target agent 1");
var targetAgent2 = CreateAgent("target2", "Target agent 2");
var handoffs = Handoffs.StartWith(sourceAgent);
// Act
var result = handoffs.Add(sourceAgent, [targetAgent1, targetAgent2]);
// Assert
Assert.Same(handoffs, result); // Should return the same instance for fluent API
Assert.Contains(sourceAgent, handoffs.Agents);
Assert.Contains(targetAgent1, handoffs.Agents);
Assert.Contains(targetAgent2, handoffs.Agents);
Assert.True(handoffs.Targets.ContainsKey(sourceAgent));
Assert.Equal(2, handoffs.Targets[sourceAgent].Count);
var targetNames = handoffs.Targets[sourceAgent].Select(t => t.Target.Id).ToArray();
Assert.Contains("target1", targetNames);
Assert.Contains("target2", targetNames);
}
[Fact]
public void Add_NullSource_ThrowsArgumentNullException()
{
// Arrange
var agent = CreateAgent("agent1", "Test agent");
var handoffs = Handoffs.StartWith(agent);
// Act & Assert
Assert.Throws<ArgumentNullException>("source", () => handoffs.Add(null!, agent));
}
[Fact]
public void Add_NullTargets_ThrowsArgumentNullException()
{
// Arrange
var sourceAgent = CreateAgent("source", "Source agent");
var handoffs = Handoffs.StartWith(sourceAgent);
// Act & Assert
Assert.Throws<ArgumentNullException>("targets", () => handoffs.Add(sourceAgent, (AIAgent[])null!));
}
[Fact]
public void Add_SingleTargetWithCustomReason_AddsHandoffWithReason()
{
// Arrange
var sourceAgent = CreateAgent("source", "Source agent");
var targetAgent = CreateAgent("target", "Target agent");
var handoffs = Handoffs.StartWith(sourceAgent);
var customReason = "Custom handoff reason";
// Act
var result = handoffs.Add(sourceAgent, targetAgent, customReason);
// Assert
Assert.Same(handoffs, result);
Assert.True(handoffs.Targets.ContainsKey(sourceAgent));
var target = handoffs.Targets[sourceAgent].Single();
Assert.Equal(targetAgent, target.Target);
Assert.Equal(customReason, target.Reason);
}
[Fact]
public void Add_SingleTargetWithNullSource_ThrowsArgumentNullException()
{
// Arrange
var agent = CreateAgent("agent1", "Test agent");
var handoffs = Handoffs.StartWith(agent);
// Act & Assert
Assert.Throws<ArgumentNullException>("source", () => handoffs.Add(null!, agent, "reason"));
}
[Fact]
public void Add_SingleTargetWithNullTarget_ThrowsArgumentNullException()
{
// Arrange
var sourceAgent = CreateAgent("source", "Source agent");
var handoffs = Handoffs.StartWith(sourceAgent);
// Act & Assert
Assert.Throws<ArgumentNullException>("target", () => handoffs.Add(sourceAgent, (AIAgent)null!, "reason"));
}
[Fact]
public void Add_DuplicateHandoff_ThrowsInvalidOperationException()
{
// Arrange
var sourceAgent = CreateAgent("source", "Source agent");
var targetAgent = CreateAgent("target", "Target agent");
var handoffs = Handoffs.StartWith(sourceAgent);
handoffs.Add(sourceAgent, targetAgent);
// Assert
Assert.Single(handoffs);
Assert.Equal("source-id", handoffs.FirstAgentName);
Assert.True(handoffs.ContainsKey("source-id"));
AgentHandoffs sourceHandoffs = handoffs["source-id"];
Assert.Single(sourceHandoffs);
Assert.Equal("Target Description", sourceHandoffs["target-id"]);
// Act & Assert
Assert.Throws<InvalidOperationException>(() => handoffs.Add(sourceAgent, targetAgent));
}
[Fact]
public void AddWithAgentWithNoDescriptionUsesName()
public void Build_WithoutName_ReturnsHandoffOrchestration()
{
// Arrange
OrchestrationHandoffs handoffs = new("source");
AIAgent sourceAgent = CreateAgent("source", "Source Agent");
AIAgent targetAgent1 = CreateAgent("target1", name: "target 1");
var agent = CreateAgent("agent1", "Test agent");
var handoffs = Handoffs.StartWith(agent);
// Act
handoffs.Add(sourceAgent, targetAgent1);
var orchestration = handoffs.Build();
// Assert
Assert.Single(handoffs);
Assert.Equal("source", handoffs.FirstAgentName);
Assert.True(handoffs.ContainsKey("source"));
AgentHandoffs sourceHandoffs = handoffs["source"];
Assert.Single(sourceHandoffs);
Assert.Equal("target 1", sourceHandoffs["target 1"]);
Assert.NotNull(orchestration);
Assert.IsType<HandoffOrchestration>(orchestration);
}
[Fact]
public void AddWithAgentWithNoDescriptionOrNameThrows()
public void Build_WithName_ReturnsHandoffOrchestrationWithName()
{
// Arrange
OrchestrationHandoffs handoffs = new("source");
AIAgent sourceAgent = CreateAgent("source", "Source Agent");
AIAgent targetAgent1 = CreateAgent("target1");
var agent = CreateAgent("agent1", "Test agent");
var handoffs = Handoffs.StartWith(agent);
var orchestrationName = "Test Orchestration";
// Act
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(() => handoffs.Add(sourceAgent, targetAgent1));
var orchestration = handoffs.Build(orchestrationName);
// Assert
Assert.Equal("The provided target agent with Id 'target1' has no description or name, and no handoff description has been provided. At least one of these are required to register a handoff so that the appropriate target agent can be chosen.", ex.Message);
Assert.NotNull(orchestration);
Assert.IsType<HandoffOrchestration>(orchestration);
Assert.Equal(orchestrationName, orchestration.Name);
}
[Fact]
public void IReadOnlyDictionary_Indexer_ReturnsTargetsForAgent()
{
// Arrange
var sourceAgent = CreateAgent("source", "Source agent");
var targetAgent = CreateAgent("target", "Target agent");
var handoffs = Handoffs.StartWith(sourceAgent);
handoffs.Add(sourceAgent, targetAgent);
var readOnlyDict = (IReadOnlyDictionary<AIAgent, IEnumerable<Handoffs.HandoffTarget>>)handoffs;
// Act
var targets = readOnlyDict[sourceAgent];
// Assert
Assert.NotNull(targets);
Assert.Single(targets);
Assert.Equal(targetAgent, targets.First().Target);
}
[Fact]
public void IReadOnlyDictionary_Keys_ReturnsSourceAgents()
{
// Arrange
var sourceAgent1 = CreateAgent("source1", "Source agent 1");
var sourceAgent2 = CreateAgent("source2", "Source agent 2");
var targetAgent = CreateAgent("target", "Target agent");
var handoffs = Handoffs.StartWith(sourceAgent1);
handoffs.Add(sourceAgent1, targetAgent);
handoffs.Add(sourceAgent2, targetAgent);
var readOnlyDict = (IReadOnlyDictionary<AIAgent, IEnumerable<Handoffs.HandoffTarget>>)handoffs;
// Act
var keys = readOnlyDict.Keys;
// Assert
Assert.Equal(2, keys.Count());
Assert.Contains(sourceAgent1, keys);
Assert.Contains(sourceAgent2, keys);
}
[Fact]
public void IReadOnlyDictionary_Values_ReturnsAllTargetCollections()
{
// Arrange
var sourceAgent = CreateAgent("source", "Source agent");
var targetAgent1 = CreateAgent("target1", "Target agent 1");
var targetAgent2 = CreateAgent("target2", "Target agent 2");
var handoffs = Handoffs.StartWith(sourceAgent);
handoffs.Add(sourceAgent, [targetAgent1, targetAgent2]);
var readOnlyDict = (IReadOnlyDictionary<AIAgent, IEnumerable<Handoffs.HandoffTarget>>)handoffs;
// Act
var values = readOnlyDict.Values;
// Assert
Assert.Single(values);
Assert.Equal(2, values.First().Count());
}
[Fact]
public void IReadOnlyDictionary_Count_ReturnsNumberOfSourceAgents()
{
// Arrange
var sourceAgent1 = CreateAgent("source1", "Source agent 1");
var sourceAgent2 = CreateAgent("source2", "Source agent 2");
var targetAgent = CreateAgent("target", "Target agent");
var handoffs = Handoffs.StartWith(sourceAgent1);
handoffs.Add(sourceAgent1, targetAgent);
handoffs.Add(sourceAgent2, targetAgent);
var readOnlyCollection = (IReadOnlyCollection<KeyValuePair<AIAgent, IEnumerable<Handoffs.HandoffTarget>>>)handoffs;
// Act
var count = readOnlyCollection.Count;
// Assert
Assert.Equal(2, count);
}
[Fact]
public void IReadOnlyDictionary_ContainsKey_ExistingAgent_ReturnsTrue()
{
// Arrange
var sourceAgent = CreateAgent("source", "Source agent");
var targetAgent = CreateAgent("target", "Target agent");
var handoffs = Handoffs.StartWith(sourceAgent);
handoffs.Add(sourceAgent, targetAgent);
var readOnlyDict = (IReadOnlyDictionary<AIAgent, IEnumerable<Handoffs.HandoffTarget>>)handoffs;
// Act
var contains = readOnlyDict.ContainsKey(sourceAgent);
// Assert
Assert.True(contains);
}
[Fact]
public void IReadOnlyDictionary_ContainsKey_NonExistingAgent_ReturnsFalse()
{
// Arrange
var sourceAgent = CreateAgent("source", "Source agent");
var otherAgent = CreateAgent("other", "Other agent");
var handoffs = Handoffs.StartWith(sourceAgent);
var readOnlyDict = (IReadOnlyDictionary<AIAgent, IEnumerable<Handoffs.HandoffTarget>>)handoffs;
// Act
var contains = readOnlyDict.ContainsKey(otherAgent);
// Assert
Assert.False(contains);
}
[Fact]
public void IReadOnlyDictionary_TryGetValue_ExistingAgent_ReturnsTrueAndValue()
{
// Arrange
var sourceAgent = CreateAgent("source", "Source agent");
var targetAgent = CreateAgent("target", "Target agent");
var handoffs = Handoffs.StartWith(sourceAgent);
handoffs.Add(sourceAgent, targetAgent);
var readOnlyDict = (IReadOnlyDictionary<AIAgent, IEnumerable<Handoffs.HandoffTarget>>)handoffs;
// Act
var success = readOnlyDict.TryGetValue(sourceAgent, out var targets);
// Assert
Assert.True(success);
Assert.NotNull(targets);
Assert.Single(targets);
Assert.Equal(targetAgent, targets.First().Target);
}
[Fact]
public void IReadOnlyDictionary_TryGetValue_NonExistingAgent_ReturnsFalseAndEmptyCollection()
{
// Arrange
var sourceAgent = CreateAgent("source", "Source agent");
var otherAgent = CreateAgent("other", "Other agent");
var handoffs = Handoffs.StartWith(sourceAgent);
var readOnlyDict = (IReadOnlyDictionary<AIAgent, IEnumerable<Handoffs.HandoffTarget>>)handoffs;
// Act
var success = readOnlyDict.TryGetValue(otherAgent, out var targets);
// Assert
Assert.False(success);
Assert.NotNull(targets);
Assert.Empty(targets);
}
[Fact]
public void IEnumerable_GetEnumerator_IteratesOverHandoffs()
{
// Arrange
var sourceAgent = CreateAgent("source", "Source agent");
var targetAgent = CreateAgent("target", "Target agent");
var handoffs = Handoffs.StartWith(sourceAgent);
handoffs.Add(sourceAgent, targetAgent);
var enumerable = (IEnumerable<KeyValuePair<AIAgent, IEnumerable<Handoffs.HandoffTarget>>>)handoffs;
// Act
var items = enumerable.ToArray();
// Assert
Assert.Single(items);
Assert.Equal(sourceAgent, items[0].Key);
Assert.Single(items[0].Value);
Assert.Equal(targetAgent, items[0].Value.First().Target);
}
[Fact]
public void IEnumerable_NonGeneric_GetEnumerator_IteratesOverHandoffs()
{
// Arrange
var sourceAgent = CreateAgent("source", "Source agent");
var targetAgent = CreateAgent("target", "Target agent");
var handoffs = Handoffs.StartWith(sourceAgent);
handoffs.Add(sourceAgent, targetAgent);
var enumerable = (IEnumerable)handoffs;
// Act
var enumerator = enumerable.GetEnumerator();
var items = new List<KeyValuePair<AIAgent, IEnumerable<Handoffs.HandoffTarget>>>();
while (enumerator.MoveNext())
{
items.Add((KeyValuePair<AIAgent, IEnumerable<Handoffs.HandoffTarget>>)enumerator.Current);
}
// Assert
Assert.Single(items);
Assert.Equal(sourceAgent, items[0].Key);
Assert.Single(items[0].Value);
Assert.Equal(targetAgent, items[0].Value.First().Target);
}
[Fact]
public void HandoffTarget_Constructor_WithValidTarget_CreatesTarget()
{
// Arrange
var agent = CreateAgent("agent1", "Test agent");
// Act
var target = new Handoffs.HandoffTarget(agent);
// Assert
Assert.Equal(agent, target.Target);
Assert.Equal("Test agent", target.Reason); // Should use description as reason
}
[Fact]
public void HandoffTarget_Constructor_WithValidTargetAndReason_CreatesTargetWithReason()
{
// Arrange
var agent = CreateAgent("agent1", "Test agent");
var reason = "Custom reason";
// Act
var target = new Handoffs.HandoffTarget(agent, reason);
// Assert
Assert.Equal(agent, target.Target);
Assert.Equal(reason, target.Reason);
}
[Fact]
public void HandoffTarget_Constructor_WithNullTarget_ThrowsArgumentNullException()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new Handoffs.HandoffTarget(null!));
}
[Fact]
public void HandoffTarget_Constructor_WithAgentWithoutDescriptionOrName_ThrowsInvalidOperationException()
{
// Arrange
var agent = CreateAgent("agent1"); // No description or name
// Act & Assert
Assert.Throws<InvalidOperationException>(() => new Handoffs.HandoffTarget(agent));
}
[Fact]
public void HandoffTarget_Constructor_WithAgentWithNameButNoDescription_UsesName()
{
// Arrange
var agent = CreateAgent("agent1", description: null, name: "Agent Name");
// Act
var target = new Handoffs.HandoffTarget(agent);
// Assert
Assert.Equal(agent, target.Target);
Assert.Equal("Agent Name", target.Reason);
}
[Fact]
public void HandoffTarget_Constructor_WithEmptyReason_UsesAgentDescription()
{
// Arrange
var agent = CreateAgent("agent1", "Test agent");
// Act
var target = new Handoffs.HandoffTarget(agent, "");
// Assert
Assert.Equal(agent, target.Target);
Assert.Equal("Test agent", target.Reason);
}
[Fact]
public void HandoffTarget_Constructor_WithWhitespaceReason_UsesAgentDescription()
{
// Arrange
var agent = CreateAgent("agent1", "Test agent");
// Act
var target = new Handoffs.HandoffTarget(agent, " ");
// Assert
Assert.Equal(agent, target.Target);
Assert.Equal("Test agent", target.Reason);
}
[Fact]
public void HandoffTarget_Equals_SameTarget_ReturnsTrue()
{
// Arrange
var agent = CreateAgent("agent1", "Test agent");
var target1 = new Handoffs.HandoffTarget(agent, "Reason 1");
var target2 = new Handoffs.HandoffTarget(agent, "Reason 2"); // Different reason, same target
// Act
var equals = target1.Equals(target2);
// Assert
Assert.True(equals);
}
[Fact]
public void HandoffTarget_Equals_DifferentTarget_ReturnsFalse()
{
// Arrange
var agent1 = CreateAgent("agent1", "Test agent 1");
var agent2 = CreateAgent("agent2", "Test agent 2");
var target1 = new Handoffs.HandoffTarget(agent1);
var target2 = new Handoffs.HandoffTarget(agent2);
// Act
var equals = target1.Equals(target2);
// Assert
Assert.False(equals);
}
[Fact]
public void HandoffTarget_Equals_Object_SameTarget_ReturnsTrue()
{
// Arrange
var agent = CreateAgent("agent1", "Test agent");
var target1 = new Handoffs.HandoffTarget(agent);
object target2 = new Handoffs.HandoffTarget(agent);
// Act
var equals = target1.Equals(target2);
// Assert
Assert.True(equals);
}
[Fact]
public void HandoffTarget_Equals_Object_DifferentType_ReturnsFalse()
{
// Arrange
var agent = CreateAgent("agent1", "Test agent");
var target = new Handoffs.HandoffTarget(agent);
object other = "not a HandoffTarget";
// Act
var equals = target.Equals(other);
// Assert
Assert.False(equals);
}
[Fact]
public void HandoffTarget_GetHashCode_SameTarget_ReturnsSameHashCode()
{
// Arrange
var agent = CreateAgent("agent1", "Test agent");
var target1 = new Handoffs.HandoffTarget(agent, "Reason 1");
var target2 = new Handoffs.HandoffTarget(agent, "Reason 2");
// Act
var hashCode1 = target1.GetHashCode();
var hashCode2 = target2.GetHashCode();
// Assert
Assert.Equal(hashCode1, hashCode2);
}
[Fact]
public void HandoffTarget_EqualityOperator_SameTarget_ReturnsTrue()
{
// Arrange
var agent = CreateAgent("agent1", "Test agent");
var target1 = new Handoffs.HandoffTarget(agent);
var target2 = new Handoffs.HandoffTarget(agent);
// Act
var equals = target1 == target2;
// Assert
Assert.True(equals);
}
[Fact]
public void HandoffTarget_InequalityOperator_DifferentTarget_ReturnsTrue()
{
// Arrange
var agent1 = CreateAgent("agent1", "Test agent 1");
var agent2 = CreateAgent("agent2", "Test agent 2");
var target1 = new Handoffs.HandoffTarget(agent1);
var target2 = new Handoffs.HandoffTarget(agent2);
// Act
var notEquals = target1 != target2;
// Assert
Assert.True(notEquals);
}
[Fact]
public void FluentAPI_ChainMultipleAdds_WorksCorrectly()
{
// Arrange
var agent1 = CreateAgent("agent1", "Agent 1");
var agent2 = CreateAgent("agent2", "Agent 2");
var agent3 = CreateAgent("agent3", "Agent 3");
var agent4 = CreateAgent("agent4", "Agent 4");
// Act
var handoffs = Handoffs
.StartWith(agent1)
.Add(agent1, [agent2, agent3])
.Add(agent2, agent4)
.Add(agent3, agent4, "Special handoff reason");
// Assert
Assert.Equal(agent1, handoffs.InitialAgent);
Assert.Equal(4, handoffs.Agents.Count);
Assert.Equal(3, handoffs.Targets.Count);
// Verify agent1 handoffs
Assert.Equal(2, handoffs.Targets[agent1].Count);
// Verify agent2 handoffs
Assert.Single(handoffs.Targets[agent2]);
Assert.Equal(agent4, handoffs.Targets[agent2].First().Target);
// Verify agent3 handoffs
Assert.Single(handoffs.Targets[agent3]);
Assert.Equal(agent4, handoffs.Targets[agent3].First().Target);
Assert.Equal("Special handoff reason", handoffs.Targets[agent3].First().Reason);
}
private static ChatClientAgent CreateAgent(string id, string? description = null, string? name = null)