Skip to content

Commit 7d72f89

Browse files
authored
.Net Agent Orchestration - Fix GroupChat Samples (#12794)
### Motivation and Context <!-- Thank you for your contribution to the semantic-kernel repo! Please help reviewers and future users, providing the following information: 1. Why is this change required? 2. What problem does it solve? 3. What scenario does it contribute to? 4. If it fixes an open issue, please link to the issue here. --> Fixes: #12731 Fixes: #12646 Fixes: #12592 Fixes: #12626 Orchestration not providing the expected result. ### Description <!-- Describe your changes, the overall approach, the underlying design. These notes will help understanding how your code works. Thanks! --> Undefined termination condition results in orchestration always running until the maximum number of turns. This result in blank messages present in the history as the agents have no completed the task. In addition, the orchestration result for the sample is not the last message (which is the default for `RoundRobinGroupChatManager`). The result is always the writer output. Also: - Addresses bizarre issue in `AgentActor` where response variable assignment from function closure evaluate to null. - Convert `PublishMessageAsync` to true "fire and forget" ### Contribution Checklist <!-- Before submitting this PR, please make sure: --> - [X] The code builds clean without any errors or warnings - [X] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [X] All unit tests pass, and I have added new tests where possible - [X] I didn't break anyone 😄
1 parent 763e362 commit 7d72f89

File tree

8 files changed

+113
-27
lines changed

8 files changed

+113
-27
lines changed

dotnet/samples/GettingStartedWithAgents/Orchestration/Step03_GroupChat.cs

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using Microsoft.SemanticKernel.Agents.Orchestration;
66
using Microsoft.SemanticKernel.Agents.Orchestration.GroupChat;
77
using Microsoft.SemanticKernel.Agents.Runtime.InProcess;
8+
using Microsoft.SemanticKernel.ChatCompletion;
89

910
namespace GettingStarted.Orchestration;
1011

@@ -47,7 +48,7 @@ Consider suggestions when refining an idea.
4748
"""
4849
You are an art director who has opinions about copywriting born of a love for David Ogilvy.
4950
The goal is to determine if the given copy is acceptable to print.
50-
If so, state that it is approved.
51+
If so, state: "I Approve".
5152
If not, provide insight on how to refine suggested copy without example.
5253
""");
5354

@@ -57,7 +58,7 @@ Consider suggestions when refining an idea.
5758
OrchestrationMonitor monitor = new();
5859
// Define the orchestration
5960
GroupChatOrchestration orchestration =
60-
new(new RoundRobinGroupChatManager()
61+
new(new AuthorCriticManager(writer.Name!, editor.Name!)
6162
{
6263
MaximumInvocationCount = 5
6364
},
@@ -87,4 +88,31 @@ Consider suggestions when refining an idea.
8788
this.WriteAgentChatMessage(message);
8889
}
8990
}
91+
92+
private sealed class AuthorCriticManager(string authorName, string criticName) : RoundRobinGroupChatManager
93+
{
94+
public override ValueTask<GroupChatManagerResult<string>> FilterResults(ChatHistory history, CancellationToken cancellationToken = default)
95+
{
96+
ChatMessageContent finalResult = history.Last(message => message.AuthorName == authorName);
97+
return ValueTask.FromResult(new GroupChatManagerResult<string>($"{finalResult}") { Reason = "The approved copy." });
98+
}
99+
100+
/// <inheritdoc/>
101+
public override async ValueTask<GroupChatManagerResult<bool>> ShouldTerminate(ChatHistory history, CancellationToken cancellationToken = default)
102+
{
103+
// Has the maximum invocation count been reached?
104+
GroupChatManagerResult<bool> result = await base.ShouldTerminate(history, cancellationToken);
105+
if (!result.Value)
106+
{
107+
// If not, check if the reviewer has approved the copy.
108+
ChatMessageContent? lastMessage = history.LastOrDefault();
109+
if (lastMessage is not null && lastMessage.AuthorName == criticName && $"{lastMessage}".Contains("I Approve", StringComparison.OrdinalIgnoreCase))
110+
{
111+
// If the reviewer approves, we terminate the chat.
112+
result = new GroupChatManagerResult<bool>(true) { Reason = "The reviewer has approved the copy." };
113+
}
114+
}
115+
return result;
116+
}
117+
}
90118
}

dotnet/samples/GettingStartedWithAgents/Orchestration/Step03a_GroupChatWithHumanInTheLoop.cs

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ Consider suggestions when refining an idea.
3939
"""
4040
You are an art director who has opinions about copywriting born of a love for David Ogilvy.
4141
The goal is to determine if the given copy is acceptable to print.
42-
If so, state that it is approved.
42+
If so, state: "I Approve".
4343
If not, provide insight on how to refine suggested copy without example.
4444
""");
4545

@@ -49,14 +49,17 @@ Consider suggestions when refining an idea.
4949
OrchestrationMonitor monitor = new();
5050

5151
// Define the orchestration
52+
bool didUserRespond = false;
5253
GroupChatOrchestration orchestration =
5354
new(
54-
new CustomRoundRobinGroupChatManager()
55+
new HumanInTheLoopChatManager(writer.Name!, editor.Name!)
5556
{
5657
MaximumInvocationCount = 5,
5758
InteractiveCallback = () =>
5859
{
59-
ChatMessageContent input = new(AuthorRole.User, "I like it");
60+
// Simlulate user input that first replies "No" and then "Yes"
61+
ChatMessageContent input = new(AuthorRole.User, didUserRespond ? "Yes" : "More pizzazz");
62+
didUserRespond = true;
6063
Console.WriteLine($"\n# INPUT: {input.Content}\n");
6164
return ValueTask.FromResult(input);
6265
}
@@ -89,18 +92,42 @@ Consider suggestions when refining an idea.
8992
/// User input is achieved by overriding the default round robin manager
9093
/// to allow user input after the reviewer agent's message.
9194
/// </remarks>
92-
private sealed class CustomRoundRobinGroupChatManager : RoundRobinGroupChatManager
95+
private sealed class HumanInTheLoopChatManager(string authorName, string criticName) : RoundRobinGroupChatManager
9396
{
97+
public override ValueTask<GroupChatManagerResult<string>> FilterResults(ChatHistory history, CancellationToken cancellationToken = default)
98+
{
99+
ChatMessageContent finalResult = history.Last(message => message.AuthorName == authorName);
100+
return ValueTask.FromResult(new GroupChatManagerResult<string>($"{finalResult}") { Reason = "The approved copy." });
101+
}
102+
103+
/// <inheritdoc/>
104+
public override async ValueTask<GroupChatManagerResult<bool>> ShouldTerminate(ChatHistory history, CancellationToken cancellationToken = default)
105+
{
106+
// Has the maximum invocation count been reached?
107+
GroupChatManagerResult<bool> result = await base.ShouldTerminate(history, cancellationToken);
108+
if (!result.Value)
109+
{
110+
// If not, check if the reviewer has approved the copy.
111+
ChatMessageContent? lastMessage = history.LastOrDefault();
112+
if (lastMessage is not null && lastMessage.Role == AuthorRole.User && $"{lastMessage}".Contains("Yes", StringComparison.OrdinalIgnoreCase))
113+
{
114+
// If the reviewer approves, we terminate the chat.
115+
result = new GroupChatManagerResult<bool>(true) { Reason = "The user is satisfied with the copy." };
116+
}
117+
}
118+
return result;
119+
}
120+
94121
public override ValueTask<GroupChatManagerResult<bool>> ShouldRequestUserInput(ChatHistory history, CancellationToken cancellationToken = default)
95122
{
96-
string? lastAgent = history.LastOrDefault()?.AuthorName;
123+
ChatMessageContent? lastMessage = history.LastOrDefault();
97124

98-
if (lastAgent is null)
125+
if (lastMessage is null)
99126
{
100127
return ValueTask.FromResult(new GroupChatManagerResult<bool>(false) { Reason = "No agents have spoken yet." });
101128
}
102129

103-
if (lastAgent == "Reviewer")
130+
if (lastMessage is not null && lastMessage.AuthorName == criticName && $"{lastMessage}".Contains("I Approve", StringComparison.OrdinalIgnoreCase))
104131
{
105132
return ValueTask.FromResult(new GroupChatManagerResult<bool>(true) { Reason = "User input is needed after the reviewer's message." });
106133
}

dotnet/samples/GettingStartedWithAgents/Orchestration/Step06_DifferentAgentTypes.cs

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ await this.CreateOpenAIAssistantAgentAsync(
161161
"""
162162
You are an art director who has opinions about copywriting born of a love for David Ogilvy.
163163
The goal is to determine if the given copy is acceptable to print.
164-
If so, state that it is approved.
164+
If so, state: "I Approve".
165165
If not, provide insight on how to refine suggested copy without example.
166166
""");
167167

@@ -171,7 +171,7 @@ await this.CreateOpenAIAssistantAgentAsync(
171171
OrchestrationMonitor monitor = new();
172172
// Define the orchestration
173173
GroupChatOrchestration orchestration =
174-
new(new RoundRobinGroupChatManager()
174+
new(new AuthorCriticManager(writer.Name!, editor.Name!)
175175
{
176176
MaximumInvocationCount = 5
177177
},
@@ -285,7 +285,6 @@ public async Task HandoffOrchestrationAsync()
285285
}
286286
}
287287

288-
#region private
289288
private sealed class OrderStatusPlugin
290289
{
291290
[KernelFunction]
@@ -303,5 +302,31 @@ private sealed class OrderRefundPlugin
303302
[KernelFunction]
304303
public string ProcessReturn(string orderId, string reason) => $"Refund for order {orderId} has been processed successfully.";
305304
}
306-
#endregion
305+
306+
private sealed class AuthorCriticManager(string authorName, string criticName) : RoundRobinGroupChatManager
307+
{
308+
public override ValueTask<GroupChatManagerResult<string>> FilterResults(ChatHistory history, CancellationToken cancellationToken = default)
309+
{
310+
ChatMessageContent finalResult = history.Last(message => message.AuthorName == authorName);
311+
return ValueTask.FromResult(new GroupChatManagerResult<string>($"{finalResult}") { Reason = "The approved copy." });
312+
}
313+
314+
/// <inheritdoc/>
315+
public override async ValueTask<GroupChatManagerResult<bool>> ShouldTerminate(ChatHistory history, CancellationToken cancellationToken = default)
316+
{
317+
// Has the maximum invocation count been reached?
318+
GroupChatManagerResult<bool> result = await base.ShouldTerminate(history, cancellationToken);
319+
if (!result.Value)
320+
{
321+
// If not, check if the reviewer has approved the copy.
322+
ChatMessageContent? lastMessage = history.LastOrDefault();
323+
if (lastMessage is not null && lastMessage.AuthorName == criticName && $"{lastMessage}".Contains("I Approve", StringComparison.OrdinalIgnoreCase))
324+
{
325+
// If the reviewer approves, we terminate the chat.
326+
result = new GroupChatManagerResult<bool>(true) { Reason = "The reviewer has approved the copy." };
327+
}
328+
}
329+
return result;
330+
}
331+
}
307332
}

dotnet/src/Agents/Orchestration/AgentActor.cs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ namespace Microsoft.SemanticKernel.Agents.Orchestration;
1717
public abstract class AgentActor : OrchestrationActor
1818
{
1919
private AgentInvokeOptions? _options;
20+
private ChatMessageContent? _lastResponse;
2021

2122
/// <summary>
2223
/// Initializes a new instance of the <see cref="AgentActor"/> class.
@@ -94,9 +95,9 @@ protected async ValueTask<ChatMessageContent> InvokeAsync(IList<ChatMessageConte
9495
{
9596
this.Context.Cancellation.ThrowIfCancellationRequested();
9697

97-
ChatMessageContent? response = null;
98+
this._lastResponse = null;
9899

99-
AgentInvokeOptions options = this.GetInvokeOptions(HandleMessage);
100+
AgentInvokeOptions options = this.GetInvokeOptions(HandleMessageAsync);
100101
if (this.Context.StreamingResponseCallback == null)
101102
{
102103
// No need to utilize streaming if no callback is provided
@@ -107,11 +108,11 @@ protected async ValueTask<ChatMessageContent> InvokeAsync(IList<ChatMessageConte
107108
await this.InvokeStreamingAsync(input, options, cancellationToken).ConfigureAwait(false);
108109
}
109110

110-
return response ?? new ChatMessageContent(AuthorRole.Assistant, string.Empty);
111+
return this._lastResponse ?? new ChatMessageContent(AuthorRole.Assistant, string.Empty);
111112

112-
async Task HandleMessage(ChatMessageContent message)
113+
async Task HandleMessageAsync(ChatMessageContent message)
113114
{
114-
response = message; // Keep track of most recent response for both invocation modes
115+
this._lastResponse = message; // Keep track of most recent response for both invocation modes
115116

116117
if (this.Context.ResponseCallback is not null && !this.ResponseCallbackFilter(message))
117118
{

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ namespace Microsoft.SemanticKernel.Agents.Orchestration.GroupChat;
1111
/// A <see cref="GroupChatManager"/> that selects agents in a round-robin fashion.
1212
/// </summary>
1313
/// <remarks>
14-
/// Subclass this class to customize filter and user interaction behavior.
14+
/// Subclass this class to customize filter, termination, and user interaction behaviors.
1515
/// </remarks>
1616
public class RoundRobinGroupChatManager : GroupChatManager
1717
{

dotnet/src/Agents/Runtime/Core.Tests/AgentRuntimeExtensionsTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ public async Task RegisterImplicitAgentSubscriptionsAsync()
8686
string messageText2 = "Test message #1";
8787
await runtime.PublishMessageAsync(messageText1, topic1);
8888
await runtime.PublishMessageAsync(messageText2, topic2);
89+
await runtime.RunUntilIdleAsync();
8990

9091
// Get agent and verify it received messages
9192
AgentId registeredId = await runtime.GetAgentAsync(agentTypeName, lazy: false);

dotnet/src/Agents/Runtime/InProcess.Tests/PublishMessageTests.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ public async Task Test_PublishMessage_SingleFailure()
3737

3838
Func<Task> publishTask = async () => await fixture.RunPublishTestAsync(new TopicId("TestTopic"), new BasicMessage { Content = "1" });
3939

40-
// Test that we wrap single errors appropriately
41-
await publishTask.Should().ThrowAsync<TestException>();
40+
// Publish is fire and forget, so we expect no exception to be thrown
41+
await publishTask.Should().NotThrowAsync();
4242

4343
fixture.GetAgentInstances<ErrorAgent>().Values.Should().ContainSingle()
4444
.Which.DidThrow.Should().BeTrue("Agent should have thrown an exception");
@@ -54,8 +54,8 @@ public async Task Test_PublishMessage_MultipleFailures()
5454

5555
Func<Task> publishTask = async () => await fixture.RunPublishTestAsync(new TopicId("TestTopic"), new BasicMessage { Content = "1" });
5656

57-
// What we are really testing here is that a single exception does not prevent sending to the remaining agents
58-
await publishTask.Should().ThrowAsync<TestException>();
57+
// Publish is fire and forget, so we expect no exception to be thrown
58+
await publishTask.Should().NotThrowAsync();
5959

6060
fixture.GetAgentInstances<ErrorAgent>().Values
6161
.Should().HaveCount(2)
@@ -76,8 +76,8 @@ public async Task Test_PublishMessage_MixedSuccessFailure()
7676

7777
Func<Task> publicTask = async () => await fixture.RunPublishTestAsync(new TopicId("TestTopic"), new BasicMessage { Content = "1" });
7878

79-
// What we are really testing here is that raising exceptions does not prevent sending to the remaining agents
80-
await publicTask.Should().ThrowAsync<TestException>();
79+
// Publish is fire and forget, so we expect no exception to be thrown
80+
await publicTask.Should().NotThrowAsync();
8181

8282
fixture.GetAgentInstances<ReceiverAgent>().Values
8383
.Should().HaveCount(2, "Two ReceiverAgents should have been created")

dotnet/src/Agents/Runtime/InProcess/InProcessRuntime.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ public async Task RunUntilIdleAsync()
102102
/// <inheritdoc/>
103103
public ValueTask PublishMessageAsync(object message, TopicId topic, AgentId? sender = null, string? messageId = null, CancellationToken cancellationToken = default)
104104
{
105-
return this.ExecuteTracedAsync(async () =>
105+
return this.ExecuteTracedAsync(() =>
106106
{
107107
MessageDelivery delivery =
108108
new MessageEnvelope(message, messageId, cancellationToken)
@@ -112,7 +112,11 @@ public ValueTask PublishMessageAsync(object message, TopicId topic, AgentId? sen
112112
this._messageDeliveryQueue.Enqueue(delivery);
113113
Interlocked.Increment(ref this.messageQueueCount);
114114

115-
await delivery.ResultSink.Future.ConfigureAwait(false);
115+
#if !NETCOREAPP
116+
return Task.CompletedTask.AsValueTask();
117+
#else
118+
return ValueTask.CompletedTask;
119+
#endif
116120
});
117121
}
118122

0 commit comments

Comments
 (0)