Skip to content

Commit 233c557

Browse files
authored
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
1 parent a722452 commit 233c557

13 files changed

Lines changed: 705 additions & 279 deletions

File tree

dotnet/samples/GettingStarted/Orchestration/HandoffOrchestration_Intro.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,12 @@ public async Task RunOrchestrationAsync(bool streamedResponse)
5656
responses.Enqueue("Order ID 321");
5757
responses.Enqueue("Broken item");
5858
responses.Enqueue("No, bye");
59+
5960
// Define the orchestration
6061
HandoffOrchestration orchestration =
61-
new(OrchestrationHandoffs
62+
new(Handoffs
6263
.StartWith(triageAgent)
63-
.Add(triageAgent, statusAgent, returnAgent, refundAgent)
64+
.Add(triageAgent, [statusAgent, returnAgent, refundAgent])
6465
.Add(statusAgent, triageAgent, "Transfer to this agent if the issue is not status related")
6566
.Add(returnAgent, triageAgent, "Transfer to this agent if the issue is not return related")
6667
.Add(refundAgent, triageAgent, "Transfer to this agent if the issue is not refund related"))

dotnet/samples/GettingStarted/Orchestration/HandoffOrchestration_With_StructuredInput.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@ public async Task RunOrchestrationAsync()
4646

4747
// Define the orchestration
4848
HandoffOrchestration orchestration =
49-
new(OrchestrationHandoffs
49+
new(Handoffs
5050
.StartWith(triageAgent)
51-
.Add(triageAgent, dotnetAgent, pythonAgent))
51+
.Add(triageAgent, [dotnetAgent, pythonAgent]))
5252
{
5353
LoggerFactory = this.LoggerFactory,
5454
ResponseCallback = monitor.ResponseCallback,

dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HostApplicationBuilderAgentExtensions.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,11 @@ public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder bu
2020
ChatClientAgent target = new(chatClient, instructions, $"{name}_targetAgent");
2121
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");
2222

23-
return new HandoffOrchestration(OrchestrationHandoffs
23+
return Handoffs
2424
.StartWith(triage)
2525
.Add(triage, target, "Hand off to the target agent for handling normal customer requests.")
26-
.Add(triage, customerService, "Hand off to the customer service agent for handling rude customer inquiries."));
26+
.Add(triage, customerService, "Hand off to the customer service agent for handling rude customer inquiries.")
27+
.Build("PirateWorkflow");
2728
});
2829
var actorBuilder = builder.AddActorRuntime();
2930

dotnet/src/Microsoft.Agents.Orchestration/ConcurrentOrchestration.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,14 @@ public partial class ConcurrentOrchestration : OrchestratingAgent
1919

2020
/// <summary>Initializes a new instance of the <see cref="ConcurrentOrchestration"/> class.</summary>
2121
/// <param name="subagents">The agents participating in the orchestration.</param>
22-
public ConcurrentOrchestration(params AIAgent[] subagents) : base(subagents)
22+
public ConcurrentOrchestration(params AIAgent[] subagents) : this(subagents, name: null)
23+
{
24+
}
25+
26+
/// <summary>Initializes a new instance of the <see cref="ConcurrentOrchestration"/> class.</summary>
27+
/// <param name="subagents">The agents participating in the orchestration.</param>
28+
/// <param name="name">An optional name for this orchestrating agent.</param>
29+
public ConcurrentOrchestration(AIAgent[] subagents, string? name) : base(subagents, name)
2330
{
2431
}
2532

dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatManager.cs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,9 @@ protected GroupChatManager() { }
8686
/// <returns>A <see cref="GroupChatManagerResult{TValue}"/> indicating whether the chat should be terminated.</returns>
8787
protected internal virtual ValueTask<GroupChatManagerResult<bool>> ShouldTerminate(IReadOnlyCollection<ChatMessage> history, CancellationToken cancellationToken = default)
8888
{
89-
Interlocked.Increment(ref this._invocationCount);
90-
9189
bool resultValue = false;
9290
string reason = "Maximum number of invocations has not been reached.";
93-
if (this.InvocationCount > this.MaximumInvocationCount)
91+
if (Interlocked.Increment(ref this._invocationCount) > this.MaximumInvocationCount)
9492
{
9593
resultValue = true;
9694
reason = "Maximum number of invocations reached.";

dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatOrchestration.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,17 @@ public sealed partial class GroupChatOrchestration : OrchestratingAgent
2424
/// </summary>
2525
/// <param name="manager">The manager that controls the flow of the group-chat.</param>
2626
/// <param name="agents">The agents participating in the orchestration.</param>
27-
public GroupChatOrchestration(GroupChatManager manager, params AIAgent[] agents) : base(agents)
27+
public GroupChatOrchestration(GroupChatManager manager, params AIAgent[] agents) : this(manager, agents, name: null)
28+
{
29+
}
30+
31+
/// <summary>
32+
/// Initializes a new instance of the <see cref="GroupChatOrchestration"/> class.
33+
/// </summary>
34+
/// <param name="manager">The manager that controls the flow of the group-chat.</param>
35+
/// <param name="agents">The agents participating in the orchestration.</param>
36+
/// <param name="name">An optional name for this orchestrating agent.</param>
37+
public GroupChatOrchestration(GroupChatManager manager, AIAgent[] agents, string? name) : base(agents, name)
2838
{
2939
this._manager = Throw.IfNull(manager);
3040
}

dotnet/src/Microsoft.Agents.Orchestration/GroupChat/RoundRobinGroupChatManager.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,16 @@ public class RoundRobinGroupChatManager : GroupChatManager
1919
private int _currentAgentIndex;
2020

2121
/// <inheritdoc/>
22-
protected internal override ValueTask<GroupChatManagerResult<string>> FilterResults(IReadOnlyCollection<ChatMessage> history, CancellationToken cancellationToken = default)
22+
protected internal override ValueTask<GroupChatManagerResult<string>> FilterResults(
23+
IReadOnlyCollection<ChatMessage> history, CancellationToken cancellationToken = default)
2324
{
2425
GroupChatManagerResult<string> result = new(history.LastOrDefault()?.Text ?? string.Empty) { Reason = "Default result filter provides the final chat message." };
2526
return new ValueTask<GroupChatManagerResult<string>>(result);
2627
}
2728

2829
/// <inheritdoc/>
29-
protected internal override ValueTask<GroupChatManagerResult<string>> SelectNextAgent(IReadOnlyCollection<ChatMessage> history, GroupChatTeam team, CancellationToken cancellationToken = default)
30+
protected internal override ValueTask<GroupChatManagerResult<string>> SelectNextAgent(
31+
IReadOnlyCollection<ChatMessage> history, GroupChatTeam team, CancellationToken cancellationToken = default)
3032
{
3133
string nextAgent = team.Skip(this._currentAgentIndex).First().Key;
3234
this._currentAgentIndex = (this._currentAgentIndex + 1) % team.Count;
@@ -35,7 +37,8 @@ protected internal override ValueTask<GroupChatManagerResult<string>> SelectNext
3537
}
3638

3739
/// <inheritdoc/>
38-
protected internal override ValueTask<GroupChatManagerResult<bool>> ShouldRequestUserInput(IReadOnlyCollection<ChatMessage> history, CancellationToken cancellationToken = default)
40+
protected internal override ValueTask<GroupChatManagerResult<bool>> ShouldRequestUserInput(
41+
IReadOnlyCollection<ChatMessage> history, CancellationToken cancellationToken = default)
3942
{
4043
GroupChatManagerResult<bool> result = new(false) { Reason = "The default round-robin group chat manager does not request user input." };
4144
return new ValueTask<GroupChatManagerResult<bool>>(result);

dotnet/src/Microsoft.Agents.Orchestration/Handoffs/HandoffOrchestration.cs

Lines changed: 43 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -20,29 +20,23 @@ namespace Microsoft.Agents.Orchestration;
2020
/// </summary>
2121
public sealed partial class HandoffOrchestration : OrchestratingAgent
2222
{
23-
private readonly OrchestrationHandoffs _handoffs;
23+
private readonly Handoffs _handoffs;
2424

2525
/// <summary>
2626
/// Initializes a new instance of the <see cref="HandoffOrchestration"/> class.
2727
/// </summary>
2828
/// <param name="handoffs">Defines the handoff connections for each agent.</param>
29-
/// <param name="agents">Additional agents participating in the orchestration that weren't passed to <paramref name="handoffs"/>.</param>
30-
public HandoffOrchestration(OrchestrationHandoffs handoffs, params AIAgent[] agents) : base(
31-
agents is { Length: 0 } ? [.. handoffs.Agents] :
32-
handoffs.Agents is { Count: 0 } ? agents :
33-
[.. handoffs.Agents.Concat(agents).Distinct()])
29+
public HandoffOrchestration(Handoffs handoffs) : this(handoffs, name: null)
3430
{
35-
// Create list of distinct agent names
36-
HashSet<string> agentNames = [.. base.Agents.Select(a => a.DisplayName), handoffs.FirstAgentName];
37-
38-
// Extract names from handoffs that don't align with a member agent.
39-
// Fail fast if invalid names are present.
40-
string[] badNames = [.. handoffs.Keys.Concat(handoffs.Values.SelectMany(h => h.Keys)).Where(name => !agentNames.Contains(name))];
41-
if (badNames.Length > 0)
42-
{
43-
Throw.ArgumentException(nameof(handoffs), $"The following agents are not defined in the orchestration: {string.Join(", ", badNames)}");
44-
}
31+
}
4532

33+
/// <summary>
34+
/// Initializes a new instance of the <see cref="HandoffOrchestration"/> class.
35+
/// </summary>
36+
/// <param name="handoffs">Defines the handoff connections for each agent.</param>
37+
/// <param name="name">An optional name for this orchestrating agent.</param>
38+
public HandoffOrchestration(Handoffs handoffs, string? name) : base(handoffs.Agents.ToArray(), name)
39+
{
4640
this._handoffs = handoffs;
4741
}
4842

@@ -54,39 +48,51 @@ protected override Task<AgentRunResponse> RunCoreAsync(IReadOnlyCollection<ChatM
5448
{
5549
List<ChatMessage> allMessages = [.. messages];
5650
int originalMessageCount = allMessages.Count;
57-
return this.ResumeAsync(this._handoffs.FirstAgentName, allMessages, originalMessageCount, context, cancellationToken);
51+
return this.ResumeAsync(this._handoffs.InitialAgent, allMessages, originalMessageCount, context, cancellationToken);
5852
}
5953

6054
/// <inheritdoc />
6155
protected override Task<AgentRunResponse> ResumeCoreAsync(JsonElement checkpointState, OrchestratingAgentContext context, CancellationToken cancellationToken)
6256
{
6357
var state = checkpointState.Deserialize(OrchestrationJsonContext.Default.HandoffState) ?? throw new InvalidOperationException("The checkpoint state is invalid.");
64-
return this.ResumeAsync(state.NextAgent, state.AllMessages, state.OriginalMessageCount, context, cancellationToken);
58+
59+
AIAgent? nextAgent = null;
60+
foreach (var agent in this.Agents)
61+
{
62+
if (agent.Id == state.NextAgent)
63+
{
64+
nextAgent = agent;
65+
break;
66+
}
67+
}
68+
69+
if (nextAgent is null)
70+
{
71+
Throw.InvalidOperationException($"The next agent '{state.NextAgent}' is not defined in the orchestration.");
72+
}
73+
74+
return this.ResumeAsync(nextAgent, state.AllMessages, state.OriginalMessageCount, context, cancellationToken);
6575
}
6676

6777
/// <inheritdoc />
6878
private async Task<AgentRunResponse> ResumeAsync(
69-
string? nextAgent, List<ChatMessage> allMessages, int originalMessageCount, OrchestratingAgentContext context, CancellationToken cancellationToken)
79+
AIAgent? agent, List<ChatMessage> allMessages, int originalMessageCount, OrchestratingAgentContext context, CancellationToken cancellationToken)
7080
{
71-
Debug.Assert(nextAgent is not null);
81+
Debug.Assert(agent is not null);
7282
AgentRunResponse? response = null;
7383

74-
while (nextAgent is not null)
84+
while (agent is not null)
7585
{
76-
AIAgent? agent =
77-
this.Agents.FirstOrDefault(a => a.Name == nextAgent || a.Id == nextAgent) ??
78-
throw new InvalidOperationException($"The agent '{nextAgent}' is not defined in the orchestration.");
79-
8086
this.LogOrchestrationSubagentRunning(context, agent);
8187

82-
if (!this._handoffs.TryGetValue(agent.DisplayName, out AgentHandoffs? handoffs) || handoffs.Count == 0)
88+
if (!this._handoffs.Targets.TryGetValue(agent, out var handoffs) || handoffs.Count == 0)
8389
{
8490
// If no handoff is available, we can run the agent directly and return its response.
8591
response = await RunAsync(agent, context, allMessages, context.Options, cancellationToken).ConfigureAwait(false);
92+
this.LogOrchestrationSubagentCompleted(context, agent);
8693
allMessages.AddRange(response.Messages);
87-
nextAgent = null;
94+
agent = null;
8895
await CheckpointAsync().ConfigureAwait(false);
89-
this.LogOrchestrationSubagentCompleted(context, agent);
9096
break;
9197
}
9298

@@ -107,8 +113,9 @@ private async Task<AgentRunResponse> ResumeAsync(
107113

108114
// Invoke the next agent with all of the messages collected so far.
109115
response = await RunAsync(agent, context, allMessages, options, cancellationToken).ConfigureAwait(false);
116+
this.LogOrchestrationSubagentCompleted(context, agent);
110117
allMessages.AddRange(response.Messages);
111-
nextAgent = handoffCtx.TargetedAgent;
118+
agent = handoffCtx.TargetedAgent;
112119
RemoveHandoffFunctionCalls(response, handoffTools);
113120

114121
if (this.InteractiveCallback is not null)
@@ -118,12 +125,10 @@ private async Task<AgentRunResponse> ResumeAsync(
118125
break;
119126
}
120127

121-
nextAgent = agent.DisplayName;
122128
allMessages.Add(await this.InteractiveCallback().ConfigureAwait(false));
123129
}
124130

125131
await CheckpointAsync().ConfigureAwait(false);
126-
this.LogOrchestrationSubagentCompleted(context, agent);
127132
}
128133

129134
allMessages.RemoveRange(0, originalMessageCount);
@@ -132,7 +137,7 @@ private async Task<AgentRunResponse> ResumeAsync(
132137
return response;
133138

134139
Task CheckpointAsync() => context.Runtime is not null ?
135-
base.WriteCheckpointAsync(JsonSerializer.SerializeToElement(new(nextAgent, allMessages, originalMessageCount), OrchestrationJsonContext.Default.HandoffState), context, cancellationToken) :
140+
base.WriteCheckpointAsync(JsonSerializer.SerializeToElement(new(agent?.Id, allMessages, originalMessageCount), OrchestrationJsonContext.Default.HandoffState), context, cancellationToken) :
136141
Task.CompletedTask;
137142
}
138143

@@ -173,9 +178,9 @@ private static void RemoveHandoffFunctionCalls(AgentRunResponse response, List<A
173178
}
174179
}
175180

176-
private sealed class HandoffContext(AgentHandoffs handoffs)
181+
private sealed class HandoffContext(HashSet<Handoffs.HandoffTarget> handoffs)
177182
{
178-
public string? TargetedAgent { get; set; }
183+
public AIAgent? TargetedAgent { get; set; }
179184
public bool EndTaskInvoked { get; set; }
180185

181186
public List<AITool> CreateHandoffFunctions(bool needsEndTask)
@@ -194,16 +199,16 @@ public List<AITool> CreateHandoffFunctions(bool needsEndTask)
194199
description: "Invoke this function when all work is completed and no further interactions are required."));
195200
}
196201

197-
foreach (KeyValuePair<string, string> handoff in handoffs)
202+
foreach (Handoffs.HandoffTarget handoff in handoffs)
198203
{
199204
functions.Add(AIFunctionFactory.Create(
200205
() =>
201206
{
202-
this.TargetedAgent = handoff.Key;
207+
this.TargetedAgent = handoff.Target;
203208
Terminate();
204209
},
205-
name: $"handoff_to_{InvalidNameCharsRegex().Replace(handoff.Key, "_")}",
206-
description: handoff.Value));
210+
name: $"handoff_to_{InvalidNameCharsRegex().Replace(handoff.Target.DisplayName, "_")}",
211+
description: handoff.Reason));
207212
}
208213

209214
return functions;

0 commit comments

Comments
 (0)