From 233c557173d692818c1a4e8cb82efd4d9876eb1f Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 24 Jul 2025 12:38:23 -0400 Subject: [PATCH] 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 --- .../HandoffOrchestration_Intro.cs | 5 +- ...ndoffOrchestration_With_StructuredInput.cs | 4 +- .../HostApplicationBuilderAgentExtensions.cs | 5 +- .../ConcurrentOrchestration.cs | 9 +- .../GroupChat/GroupChatManager.cs | 4 +- .../GroupChat/GroupChatOrchestration.cs | 12 +- .../GroupChat/RoundRobinGroupChatManager.cs | 9 +- .../Handoffs/HandoffOrchestration.cs | 83 +- .../Handoffs/Handoffs.cs | 240 +++--- .../OrchestratingAgent.cs | 7 +- .../SequentialOrchestration.cs | 9 +- .../HandoffOrchestrationTests.cs | 8 +- .../HandoffsTests.cs | 739 +++++++++++++----- 13 files changed, 780 insertions(+), 354 deletions(-) diff --git a/dotnet/samples/GettingStarted/Orchestration/HandoffOrchestration_Intro.cs b/dotnet/samples/GettingStarted/Orchestration/HandoffOrchestration_Intro.cs index eceb911598..aaf0d70946 100644 --- a/dotnet/samples/GettingStarted/Orchestration/HandoffOrchestration_Intro.cs +++ b/dotnet/samples/GettingStarted/Orchestration/HandoffOrchestration_Intro.cs @@ -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")) diff --git a/dotnet/samples/GettingStarted/Orchestration/HandoffOrchestration_With_StructuredInput.cs b/dotnet/samples/GettingStarted/Orchestration/HandoffOrchestration_With_StructuredInput.cs index 45fa575dec..dd17d56891 100644 --- a/dotnet/samples/GettingStarted/Orchestration/HandoffOrchestration_With_StructuredInput.cs +++ b/dotnet/samples/GettingStarted/Orchestration/HandoffOrchestration_With_StructuredInput.cs @@ -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, diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HostApplicationBuilderAgentExtensions.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HostApplicationBuilderAgentExtensions.cs index 6d02023114..3773a2ca56 100644 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HostApplicationBuilderAgentExtensions.cs +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HostApplicationBuilderAgentExtensions.cs @@ -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(); diff --git a/dotnet/src/Microsoft.Agents.Orchestration/ConcurrentOrchestration.cs b/dotnet/src/Microsoft.Agents.Orchestration/ConcurrentOrchestration.cs index e920929b21..a28c203db3 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/ConcurrentOrchestration.cs +++ b/dotnet/src/Microsoft.Agents.Orchestration/ConcurrentOrchestration.cs @@ -19,7 +19,14 @@ public partial class ConcurrentOrchestration : OrchestratingAgent /// Initializes a new instance of the class. /// The agents participating in the orchestration. - public ConcurrentOrchestration(params AIAgent[] subagents) : base(subagents) + public ConcurrentOrchestration(params AIAgent[] subagents) : this(subagents, name: null) + { + } + + /// Initializes a new instance of the class. + /// The agents participating in the orchestration. + /// An optional name for this orchestrating agent. + public ConcurrentOrchestration(AIAgent[] subagents, string? name) : base(subagents, name) { } diff --git a/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatManager.cs b/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatManager.cs index 01436ad5fe..00fc80e444 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatManager.cs +++ b/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatManager.cs @@ -86,11 +86,9 @@ public abstract class GroupChatManager /// A indicating whether the chat should be terminated. protected internal virtual ValueTask> ShouldTerminate(IReadOnlyCollection 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."; diff --git a/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatOrchestration.cs b/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatOrchestration.cs index c79656b299..46f258715d 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatOrchestration.cs +++ b/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatOrchestration.cs @@ -24,7 +24,17 @@ public sealed partial class GroupChatOrchestration : OrchestratingAgent /// /// The manager that controls the flow of the group-chat. /// The agents participating in the orchestration. - public GroupChatOrchestration(GroupChatManager manager, params AIAgent[] agents) : base(agents) + public GroupChatOrchestration(GroupChatManager manager, params AIAgent[] agents) : this(manager, agents, name: null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The manager that controls the flow of the group-chat. + /// The agents participating in the orchestration. + /// An optional name for this orchestrating agent. + public GroupChatOrchestration(GroupChatManager manager, AIAgent[] agents, string? name) : base(agents, name) { this._manager = Throw.IfNull(manager); } diff --git a/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/RoundRobinGroupChatManager.cs b/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/RoundRobinGroupChatManager.cs index 1e76ed4dd8..d01faf3dd8 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/RoundRobinGroupChatManager.cs +++ b/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/RoundRobinGroupChatManager.cs @@ -19,14 +19,16 @@ public class RoundRobinGroupChatManager : GroupChatManager private int _currentAgentIndex; /// - protected internal override ValueTask> FilterResults(IReadOnlyCollection history, CancellationToken cancellationToken = default) + protected internal override ValueTask> FilterResults( + IReadOnlyCollection history, CancellationToken cancellationToken = default) { GroupChatManagerResult result = new(history.LastOrDefault()?.Text ?? string.Empty) { Reason = "Default result filter provides the final chat message." }; return new ValueTask>(result); } /// - protected internal override ValueTask> SelectNextAgent(IReadOnlyCollection history, GroupChatTeam team, CancellationToken cancellationToken = default) + protected internal override ValueTask> SelectNextAgent( + IReadOnlyCollection 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 } /// - protected internal override ValueTask> ShouldRequestUserInput(IReadOnlyCollection history, CancellationToken cancellationToken = default) + protected internal override ValueTask> ShouldRequestUserInput( + IReadOnlyCollection history, CancellationToken cancellationToken = default) { GroupChatManagerResult result = new(false) { Reason = "The default round-robin group chat manager does not request user input." }; return new ValueTask>(result); diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Handoffs/HandoffOrchestration.cs b/dotnet/src/Microsoft.Agents.Orchestration/Handoffs/HandoffOrchestration.cs index 09449de2f0..d1c9c5568e 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Handoffs/HandoffOrchestration.cs +++ b/dotnet/src/Microsoft.Agents.Orchestration/Handoffs/HandoffOrchestration.cs @@ -20,29 +20,23 @@ namespace Microsoft.Agents.Orchestration; /// public sealed partial class HandoffOrchestration : OrchestratingAgent { - private readonly OrchestrationHandoffs _handoffs; + private readonly Handoffs _handoffs; /// /// Initializes a new instance of the class. /// /// Defines the handoff connections for each agent. - /// Additional agents participating in the orchestration that weren't passed to . - 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 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)}"); - } + } + /// + /// Initializes a new instance of the class. + /// + /// Defines the handoff connections for each agent. + /// An optional name for this orchestrating agent. + 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 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); } /// protected override Task 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); } /// private async Task ResumeAsync( - string? nextAgent, List allMessages, int originalMessageCount, OrchestratingAgentContext context, CancellationToken cancellationToken) + AIAgent? agent, List 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) { - public string? TargetedAgent { get; set; } + public AIAgent? TargetedAgent { get; set; } public bool EndTaskInvoked { get; set; } public List 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 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; diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Handoffs/Handoffs.cs b/dotnet/src/Microsoft.Agents.Orchestration/Handoffs/Handoffs.cs index 50d36f0001..a40878149a 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Handoffs/Handoffs.cs +++ b/dotnet/src/Microsoft.Agents.Orchestration/Handoffs/Handoffs.cs @@ -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; -/// -/// Defines the handoff relationships for a given agent. -/// Maps target agent names/IDs to handoff descriptions. -/// -public sealed class AgentHandoffs : Dictionary -{ - /// - /// Initializes a new instance of the class with no handoff relationships. - /// - public AgentHandoffs() { } - - /// - /// Initializes a new instance of the class with the specified handoff relationships. - /// - /// A dictionary mapping target agent names/IDs to handoff descriptions. - public AgentHandoffs(Dictionary handoffs) : base(handoffs) { } -} - /// /// Defines the orchestration handoff relationships for all agents in the system. -/// Maps source agent names/IDs to their . /// -public sealed class OrchestrationHandoffs : Dictionary +public sealed class Handoffs : + IReadOnlyDictionary> { /// - /// Initializes a new instance of the class with no handoff relationships. + /// Initializes a new instance of the class with no handoff relationships. /// - /// The first agent to be invoked (prior to any handoff). - public OrchestrationHandoffs(AIAgent firstAgent) - : this(firstAgent.DisplayName) + /// The first agent to be invoked (prior to any handoff). + private Handoffs(AIAgent initialAgent) { - this.Agents.Add(firstAgent); + Throw.IfNull(initialAgent); + + this.Agents.Add(initialAgent); + this.InitialAgent = initialAgent; } - /// - /// Initializes a new instance of the class with no handoff relationships. - /// - /// The name of the first agent to be invoked (prior to any handoff). - public OrchestrationHandoffs(string firstAgentName) - { - Throw.IfNullOrWhitespace(firstAgentName, nameof(firstAgentName)); - this.FirstAgentName = firstAgentName; - } + /// Gets the initial agent to which the first messages will be sent. + public AIAgent InitialAgent { get; } + + /// Gets a collection of all handoff targets, indexed by the source of the handoffs. + internal Dictionary> Targets { get; } = []; + + /// Gets a set of all agents involved in the handoffs, sources and targets. + internal HashSet Agents { get; } = []; /// - /// 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. /// - public string FirstAgentName { get; } + /// The initial agent. + /// The new instance. + public static Handoffs StartWith(AIAgent initialAgent) => new(initialAgent); + + /// Creates a new from the described handoffs. + /// An optional name for this orchestrating agent. + /// The new . + public HandoffOrchestration Build(string? name = null) => new(this, name); /// /// 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. - /// - /// The source agent. - /// The updated instance. - public static OrchestrationHandoffs StartWith(AIAgent source) => new(source); - - /// - /// 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. /// /// The source agent. /// The target agents to add as handoff targets for the source agent. - /// The updated instance. - public OrchestrationHandoffs Add(AIAgent source, params AIAgent[] targets) + /// The updated instance. + /// The handoff reason for each target is derived from its description or name. + 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; } /// - /// 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. /// /// The source agent. /// The target agent. - /// The handoff description. - /// The updated instance. - public OrchestrationHandoffs Add(AIAgent source, AIAgent target, string description) + /// The reason the should hand off to the . + /// The updated instance. + 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); - } - /// - /// Adds a handoff relationship from a source agent to a target agent name/ID with a custom description. - /// - /// The source agent. - /// The target agent's name or ID. - /// The handoff description. - /// The updated instance. - 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 = []; + } - /// - /// Adds a handoff relationship from a source agent name/ID to a target agent name/ID with a custom description. - /// - /// The source agent's name or ID. - /// The target agent's name or ID. - /// The handoff description. - /// The updated instance. - 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 = []; - } + /// + IEnumerable IReadOnlyDictionary>.this[AIAgent key] => this.Targets[key]; - return agentHandoffs; + /// + IEnumerable IReadOnlyDictionary>.Keys => this.Targets.Keys; + + /// + IEnumerable> IReadOnlyDictionary>.Values => this.Targets.Values; + + /// + int IReadOnlyCollection>>.Count => this.Targets.Count; + + /// + bool IReadOnlyDictionary>.ContainsKey(AIAgent key) => this.Targets.ContainsKey(key); + + /// + IEnumerator>> IEnumerable>>.GetEnumerator() + { + foreach (var kvp in this.Targets) + { + yield return new(kvp.Key, kvp.Value); + } } - internal HashSet Agents { get; } = []; -} + /// + IEnumerator IEnumerable.GetEnumerator() => + ((IReadOnlyDictionary>)this).GetEnumerator(); -/// -/// 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 and handoff description. -/// -internal sealed class HandoffLookup : Dictionary; + /// + bool IReadOnlyDictionary>.TryGetValue(AIAgent key, out IEnumerable value) + { + if (this.Targets.TryGetValue(key, out var handoffs)) + { + value = handoffs; + return true; + } + + value = []; + return false; + } + + /// Describes a handoff to a specific target . + public readonly struct HandoffTarget : IEquatable + { + 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!; + } + + /// Gets the target of the handoff. + public AIAgent Target { get; } + + /// Gets the reason a handoff to should be performed. + public string Reason { get; } + + /// + public bool Equals(HandoffTarget other) => this.Target == other.Target; + + /// + public override bool Equals(object? obj) => obj is HandoffTarget other && this.Equals(other); + + /// + public override int GetHashCode() => this.Target.GetHashCode(); + + /// + public static bool operator ==(HandoffTarget left, HandoffTarget right) => left.Equals(right); + + /// + public static bool operator !=(HandoffTarget left, HandoffTarget right) => !left.Equals(right); + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/OrchestratingAgent.cs b/dotnet/src/Microsoft.Agents.Orchestration/OrchestratingAgent.cs index 5a926c4d43..cf974cd6ef 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/OrchestratingAgent.cs +++ b/dotnet/src/Microsoft.Agents.Orchestration/OrchestratingAgent.cs @@ -27,13 +27,18 @@ public abstract partial class OrchestratingAgent : AIAgent /// Initializes a new instance of the class. /// /// Specifies the agents participating in this orchestration. - protected OrchestratingAgent(IReadOnlyList agents) + /// An optional name for this agent. + protected OrchestratingAgent(IReadOnlyList agents, string? name = null) { _ = Throw.IfNullOrEmpty(agents); this.Agents = agents; + this.Name = name; } + /// + public override string? Name { get; } + /// /// Gets the list of member targets involved in the orchestration. /// diff --git a/dotnet/src/Microsoft.Agents.Orchestration/SequentialOrchestration.cs b/dotnet/src/Microsoft.Agents.Orchestration/SequentialOrchestration.cs index 7ec5b9f828..6f79bb4129 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/SequentialOrchestration.cs +++ b/dotnet/src/Microsoft.Agents.Orchestration/SequentialOrchestration.cs @@ -16,7 +16,14 @@ public sealed partial class SequentialOrchestration : OrchestratingAgent { /// Initializes a new instance of the class. /// The agents participating in the orchestration. - public SequentialOrchestration(params AIAgent[] agents) : base(agents) + public SequentialOrchestration(params AIAgent[] agents) : this(agents, name: null) + { + } + + /// Initializes a new instance of the class. + /// The agents participating in the orchestration. + /// An optional name for this orchestrating agent. + public SequentialOrchestration(AIAgent[] agents, string? name) : base(agents, name) { } diff --git a/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/HandoffOrchestrationTests.cs b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/HandoffOrchestrationTests.cs index 6e055b27c6..e916837dc6 100644 --- a/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/HandoffOrchestrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/HandoffOrchestrationTests.cs @@ -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 ExecuteOrchestrationAsync(OrchestrationHandoffs handoffs) + private static async Task ExecuteOrchestrationAsync(Handoffs handoffs) { // Arrange HandoffOrchestration orchestration = new(handoffs); diff --git a/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/HandoffsTests.cs b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/HandoffsTests.cs index 87ed27ea80..2068183383 100644 --- a/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/HandoffsTests.cs +++ b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/HandoffsTests.cs @@ -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(() => new OrchestrationHandoffs((string)null!)); - Assert.Throws(() => new OrchestrationHandoffs(string.Empty)); - Assert.Throws(() => 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("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("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("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("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("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(() => 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(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(() => 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(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>)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>)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>)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>>)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>)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>)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>)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>)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>>)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>>(); + while (enumerator.MoveNext()) + { + items.Add((KeyValuePair>)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(() => new Handoffs.HandoffTarget(null!)); + } + + [Fact] + public void HandoffTarget_Constructor_WithAgentWithoutDescriptionOrName_ThrowsInvalidOperationException() + { + // Arrange + var agent = CreateAgent("agent1"); // No description or name + + // Act & Assert + Assert.Throws(() => 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)