mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
committed by
GitHub
Unverified
parent
a72245287f
commit
233c557173
@@ -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"))
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+3
-2
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user