diff --git a/dotnet/samples/AgentSyntaxExamples/Example01_Agent.cs b/dotnet/samples/AgentSyntaxExamples/Example01_Agent.cs index 4c113b9a1682..17370ddc0265 100644 --- a/dotnet/samples/AgentSyntaxExamples/Example01_Agent.cs +++ b/dotnet/samples/AgentSyntaxExamples/Example01_Agent.cs @@ -1,6 +1,4 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Collections.Generic; -using System.Threading; using System.Threading.Tasks; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Agents; @@ -32,7 +30,7 @@ public async Task RunAsync() }; // Create a chat for agent interaction. For more, see: Example03_Chat. - var chat = new TestChat(); + AgentGroupChat chat = new(); // Respond to user input await InvokeAgentAsync("Fortune favors the bold."); @@ -52,18 +50,4 @@ async Task InvokeAgentAsync(string input) } } } - - /// - /// A simple chat for the agent example. - /// - /// - /// For further exploration of , see: Example03_Chat. - /// - private sealed class TestChat : AgentChat - { - public IAsyncEnumerable InvokeAsync( - Agent agent, - CancellationToken cancellationToken = default) => - base.InvokeAgentAsync(agent, cancellationToken); - } } diff --git a/dotnet/samples/AgentSyntaxExamples/Example02_Plugins.cs b/dotnet/samples/AgentSyntaxExamples/Example02_Plugins.cs index 5f81d41b6d7f..6e4910245350 100644 --- a/dotnet/samples/AgentSyntaxExamples/Example02_Plugins.cs +++ b/dotnet/samples/AgentSyntaxExamples/Example02_Plugins.cs @@ -1,6 +1,4 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Collections.Generic; -using System.Threading; using System.Threading.Tasks; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Agents; @@ -39,7 +37,7 @@ public async Task RunAsync() agent.Kernel.Plugins.Add(plugin); // Create a chat for agent interaction. For more, see: Example03_Chat. - var chat = new TestChat(); + AgentGroupChat chat = new(); // Respond to user input, invoking functions where appropriate. await InvokeAgentAsync("Hello"); @@ -59,19 +57,4 @@ async Task InvokeAgentAsync(string input) } } } - - /// - /// - /// A simple chat for the agent example. - /// - /// - /// For further exploration of , see: Example03_Chat. - /// - private sealed class TestChat : AgentChat - { - public IAsyncEnumerable InvokeAsync( - Agent agent, - CancellationToken cancellationToken = default) => - base.InvokeAgentAsync(agent, cancellationToken); - } } diff --git a/dotnet/samples/AgentSyntaxExamples/Example03_Chat.cs b/dotnet/samples/AgentSyntaxExamples/Example03_Chat.cs new file mode 100644 index 000000000000..fedc422ce503 --- /dev/null +++ b/dotnet/samples/AgentSyntaxExamples/Example03_Chat.cs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft. All rights reserved. +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.Agents.Chat; +using Microsoft.SemanticKernel.ChatCompletion; +using Xunit; +using Xunit.Abstractions; + +namespace Examples; + +/// +/// Demonstrate creation of with +/// that inform how chat proceeds with regards to: Agent selection, chat continuation, and maximum +/// number of agent interactions. +/// +public class Example03_Chat(ITestOutputHelper output) : BaseTest(output) +{ + private const string ReviewerName = "ArtDirector"; + private const string ReviewerInstructions = + """ + You are an art director who has opinions about copywriting born of a love for David Ogilvy. + The goal is to determine is the given copy is acceptable to print. + If so, state that it is approved. + If not, provide insight on how to refine suggested copy without example. + """; + + private const string CopyWriterName = "Writer"; + private const string CopyWriterInstructions = + """ + You are a copywriter with ten years of experience and are known for brevity and a dry humor. + You're laser focused on the goal at hand. Don't waste time with chit chat. + The goal is to refine and decide on the single best copy as an expert in the field. + Consider suggestions when refining an idea. + """; + + [Fact] + public async Task RunAsync() + { + // Define the agents + ChatCompletionAgent agentReviewer = + new() + { + Instructions = ReviewerInstructions, + Name = ReviewerName, + Kernel = this.CreateKernelWithChatCompletion(), + }; + + ChatCompletionAgent agentWriter = + new() + { + Instructions = CopyWriterInstructions, + Name = CopyWriterName, + Kernel = this.CreateKernelWithChatCompletion(), + }; + + // Create a chat for agent interaction. + AgentGroupChat chat = + new(agentWriter, agentReviewer) + { + ExecutionSettings = + new() + { + // Here a TerminationStrategy subclass is used that will terminate when + // an assistant message contains the term "approve". + TerminationStrategy = + new ApprovalTerminationStrategy() + { + // Only the art-director may approve. + Agents = [agentReviewer], + } + } + }; + + // Invoke chat and display messages. + string input = "concept: maps made out of egg cartons."; + chat.AddChatMessage(new ChatMessageContent(AuthorRole.User, input)); + this.WriteLine($"# {AuthorRole.User}: '{input}'"); + + await foreach (var content in chat.InvokeAsync()) + { + this.WriteLine($"# {content.Role} - {content.AuthorName ?? "*"}: '{content.Content}'"); + } + + this.WriteLine($"# IS COMPLETE: {chat.IsComplete}"); + } + + private sealed class ApprovalTerminationStrategy : TerminationStrategy + { + // Terminate when the final message contains the term "approve" + protected override Task ShouldAgentTerminateAsync(Agent agent, IReadOnlyList history, CancellationToken cancellationToken) + => Task.FromResult(history[history.Count - 1].Content?.Contains("approve", StringComparison.OrdinalIgnoreCase) ?? false); + } +} diff --git a/dotnet/src/Agents/Abstractions/AgentChat.cs b/dotnet/src/Agents/Abstractions/AgentChat.cs index 6f436029e8b4..e3004ce00ef0 100644 --- a/dotnet/src/Agents/Abstractions/AgentChat.cs +++ b/dotnet/src/Agents/Abstractions/AgentChat.cs @@ -19,10 +19,14 @@ public abstract class AgentChat private readonly BroadcastQueue _broadcastQueue; private readonly Dictionary _agentChannels; private readonly Dictionary _channelMap; - private readonly ChatHistory _history; private int _isActive; + /// + /// Exposes the internal history to subclasses. + /// + protected ChatHistory History { get; } + /// /// Retrieve the message history, either the primary history or /// an agent specific version. @@ -34,7 +38,7 @@ public IAsyncEnumerable GetChatMessagesAsync(Agent? agent = { if (agent == null) { - return this._history.ToDescendingAsync(); + return this.History.ToDescendingAsync(); } var channelKey = this.GetAgentHash(agent); @@ -80,7 +84,7 @@ public void AddChatMessages(IReadOnlyList messages) } // Append to chat history - this._history.AddRange(messages); + this.History.AddRange(messages); // Broadcast message to other channels (in parallel) var channelRefs = this._agentChannels.Select(kvp => new ChannelReference(kvp.Value, kvp.Key)); @@ -114,7 +118,7 @@ protected async IAsyncEnumerable InvokeAgentAsync( await foreach (var message in channel.InvokeAsync(agent, cancellationToken).ConfigureAwait(false)) { // Add to primary history - this._history.Add(message); + this.History.Add(message); messages.Add(message); // Yield message to caller @@ -146,9 +150,9 @@ private async Task GetChannelAsync(Agent agent, CancellationToken { channel = await agent.CreateChannelAsync(cancellationToken).ConfigureAwait(false); - if (this._history.Count > 0) + if (this.History.Count > 0) { - await channel.ReceiveAsync(this._history, cancellationToken).ConfigureAwait(false); + await channel.ReceiveAsync(this.History, cancellationToken).ConfigureAwait(false); } this._agentChannels.Add(channelKey, channel); @@ -179,6 +183,6 @@ protected AgentChat() this._agentChannels = []; this._broadcastQueue = new(); this._channelMap = []; - this._history = []; + this.History = []; } } diff --git a/dotnet/src/Agents/Core/AgentGroupChat.cs b/dotnet/src/Agents/Core/AgentGroupChat.cs new file mode 100644 index 000000000000..70e343834642 --- /dev/null +++ b/dotnet/src/Agents/Core/AgentGroupChat.cs @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.SemanticKernel.Agents.Chat; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace Microsoft.SemanticKernel.Agents; + +/// +/// A an that supports multi-turn interactions. +/// +public sealed class AgentGroupChat : AgentChat +{ + private readonly HashSet _agentIds; // Efficient existence test + private readonly List _agents; // Maintain order + + /// + /// Indicates if completion criteria has been met. If set, no further + /// agent interactions will occur. Clear to enable more agent interactions. + /// + public bool IsComplete { get; set; } + + /// + /// Settings for defining chat behavior. + /// + public AgentGroupChatSettings ExecutionSettings { get; set; } = new AgentGroupChatSettings(); + + /// + /// The agents participating in the chat. + /// + public IReadOnlyList Agents => this._agents.AsReadOnly(); + + /// + /// Add a to the chat. + /// + /// The to add. + public void AddAgent(Agent agent) + { + if (this._agentIds.Add(agent.Id)) + { + this._agents.Add(agent); + } + } + + /// + /// Process a series of interactions between the that have joined this . + /// The interactions will proceed according to the and the + /// defined via . + /// In the absence of an , this method will not invoke any agents. + /// Any agent may be explicitly selected by calling . + /// + /// The to monitor for cancellation requests. The default is . + /// Asynchronous enumeration of messages. + public async IAsyncEnumerable InvokeAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (this.IsComplete) + { + // Throw exception if chat is completed and automatic-reset is not enabled. + if (!this.ExecutionSettings.TerminationStrategy.AutomaticReset) + { + throw new KernelException("Agent Failure - Chat has completed."); + } + + this.IsComplete = false; + } + + for (int index = 0; index < this.ExecutionSettings.TerminationStrategy.MaximumIterations; index++) + { + // Identify next agent using strategy + Agent agent = await this.ExecutionSettings.SelectionStrategy.NextAsync(this.Agents, this.History, cancellationToken).ConfigureAwait(false); + + // Invoke agent and process messages along with termination + await foreach (var message in base.InvokeAgentAsync(agent, cancellationToken).ConfigureAwait(false)) + { + if (message.Role == AuthorRole.Assistant) + { + var task = this.ExecutionSettings.TerminationStrategy.ShouldTerminateAsync(agent, this.History, cancellationToken); + this.IsComplete = await task.ConfigureAwait(false); + } + + yield return message; + } + + if (this.IsComplete) + { + break; + } + } + } + + /// + /// Process a single interaction between a given an a . + /// + /// The agent actively interacting with the chat. + /// The to monitor for cancellation requests. The default is . + /// Asynchronous enumeration of messages. + /// + /// Specified agent joins the chat. + /// > + public IAsyncEnumerable InvokeAsync( + Agent agent, + CancellationToken cancellationToken = default) => + this.InvokeAsync(agent, isJoining: true, cancellationToken); + + /// + /// Process a single interaction between a given an a irregardless of + /// the defined via . Likewise, this does + /// not regard as it only takes a single turn for the specified agent. + /// + /// The agent actively interacting with the chat. + /// Optional flag to control if agent is joining the chat. + /// The to monitor for cancellation requests. The default is . + /// Asynchronous enumeration of messages. + public async IAsyncEnumerable InvokeAsync( + Agent agent, + bool isJoining, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (isJoining) + { + this.AddAgent(agent); + } + + await foreach (var message in base.InvokeAgentAsync(agent, cancellationToken).ConfigureAwait(false)) + { + if (message.Role == AuthorRole.Assistant) + { + var task = this.ExecutionSettings.TerminationStrategy.ShouldTerminateAsync(agent, this.History, cancellationToken); + this.IsComplete = await task.ConfigureAwait(false); + } + + yield return message; + } + } + + /// + /// Initializes a new instance of the class. + /// + /// The agents initially participating in the chat. + public AgentGroupChat(params Agent[] agents) + { + this._agents = new(agents); + this._agentIds = new(this._agents.Select(a => a.Id)); + } +} diff --git a/dotnet/src/Agents/Core/Chat/AgentGroupChatSettings.cs b/dotnet/src/Agents/Core/Chat/AgentGroupChatSettings.cs new file mode 100644 index 000000000000..f7b2d87fb7e8 --- /dev/null +++ b/dotnet/src/Agents/Core/Chat/AgentGroupChatSettings.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.SemanticKernel.Agents.Chat; + +/// +/// Settings that affect behavior of . +/// +/// +/// Default behavior result in no agent selection. +/// +public class AgentGroupChatSettings +{ + /// + /// Strategy for selecting the next agent. Dfeault strategy limited to a single iteration and no termination criteria. + /// + /// + /// See . + /// + public TerminationStrategy TerminationStrategy { get; init; } = new DefaultTerminationStrategy(); + + /// + /// Strategy for selecting the next agent. Defaults to . + /// + /// + /// See . + /// + public SelectionStrategy SelectionStrategy { get; init; } = new SequentialSelectionStrategy(); + + /// + /// The termination strategy attached to the default state of . + /// This strategy will execute without signaling termination. Execution of will only be + /// bound by . + /// + internal sealed class DefaultTerminationStrategy : TerminationStrategy + { + /// + protected override Task ShouldAgentTerminateAsync(Agent agent, IReadOnlyList history, CancellationToken cancellationToken = default) + { + return Task.FromResult(false); + } + + public DefaultTerminationStrategy() + { + this.MaximumIterations = 1; + } + } +} diff --git a/dotnet/src/Agents/Core/Chat/AggregatorTerminationStrategy.cs b/dotnet/src/Agents/Core/Chat/AggregatorTerminationStrategy.cs new file mode 100644 index 000000000000..9fb3c9a47f86 --- /dev/null +++ b/dotnet/src/Agents/Core/Chat/AggregatorTerminationStrategy.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.SemanticKernel.Agents.Chat; + +/// +/// Defines aggregation behavior for +/// +public enum AggregateTerminationCondition +{ + /// + /// All aggregated strategies must agree on termination. + /// + All, + + /// + /// Any single aggregated strategy will terminate. + /// + Any, +} + +/// +/// Aggregate a set of objects. +/// +/// Set of strategies upon which to aggregate. +public sealed class AggregatorTerminationStrategy(params TerminationStrategy[] strategies) : TerminationStrategy +{ + private readonly TerminationStrategy[] _strategies = strategies; + + /// + /// Logical operation for aggregation: All or Any (and/or). Default: All. + /// + public AggregateTerminationCondition Condition { get; init; } = AggregateTerminationCondition.All; + + /// + protected override async Task ShouldAgentTerminateAsync(Agent agent, IReadOnlyList history, CancellationToken cancellationToken = default) + { + var strategyExecution = this._strategies.Select(s => s.ShouldTerminateAsync(agent, history, cancellationToken)); + + var results = await Task.WhenAll(strategyExecution).ConfigureAwait(false); + bool shouldTerminate = + this.Condition == AggregateTerminationCondition.All ? + results.All(r => r) : + results.Any(r => r); + + return shouldTerminate; + } +} diff --git a/dotnet/src/Agents/Core/Chat/RegExTerminationStrategy.cs b/dotnet/src/Agents/Core/Chat/RegExTerminationStrategy.cs new file mode 100644 index 000000000000..3164dd4760cc --- /dev/null +++ b/dotnet/src/Agents/Core/Chat/RegExTerminationStrategy.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.SemanticKernel.Agents.Chat; + +/// +/// Signals termination when the most recent message matches against the defined regular expressions +/// for the specified agent (if provided). +/// +public sealed class RegExTerminationStrategy : TerminationStrategy +{ + private readonly string[] _expressions; + + /// + protected override Task ShouldAgentTerminateAsync(Agent agent, IReadOnlyList history, CancellationToken cancellationToken = default) + { + // Most recent message + var message = history[history.Count - 1]; + + // Evaluate expressions for match + foreach (var expression in this._expressions) + { + if (Regex.IsMatch(message.Content, expression)) + { + return Task.FromResult(true); + } + } + + return Task.FromResult(false); + } + + /// + /// Initializes a new instance of the class. + /// + /// A list of regular expressions, that if + public RegExTerminationStrategy(params string[] expressions) + { + this._expressions = expressions; + } +} diff --git a/dotnet/src/Agents/Core/Chat/SelectionStrategy.cs b/dotnet/src/Agents/Core/Chat/SelectionStrategy.cs new file mode 100644 index 000000000000..ed43df98c4b8 --- /dev/null +++ b/dotnet/src/Agents/Core/Chat/SelectionStrategy.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.SemanticKernel.Agents.Chat; + +/// +/// Base strategy class for selecting the next agent for a . +/// +public abstract class SelectionStrategy +{ + /// + /// Determine which agent goes next. + /// + /// The agents participating in chat. + /// The chat history. + /// The to monitor for cancellation requests. The default is . + /// The agent who shall take the next turn. + public abstract Task NextAsync(IReadOnlyList agents, IReadOnlyList history, CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Agents/Core/Chat/SequentialSelectionStrategy.cs b/dotnet/src/Agents/Core/Chat/SequentialSelectionStrategy.cs new file mode 100644 index 000000000000..0532ed90c6f1 --- /dev/null +++ b/dotnet/src/Agents/Core/Chat/SequentialSelectionStrategy.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.SemanticKernel.Agents.Chat; + +/// +/// Round-robin turn-taking strategy. Agent order is based on the order +/// in which they joined . +/// +public sealed class SequentialSelectionStrategy : SelectionStrategy +{ + private int _index = 0; + + /// + /// Reset selection to initial/first agent. Agent order is based on the order + /// in which they joined . + /// + public void Reset() => this._index = 0; + + /// + public override Task NextAsync(IReadOnlyList agents, IReadOnlyList history, CancellationToken cancellationToken = default) + { + if (agents.Count == 0) + { + throw new KernelException("Agent Failure - No agents present to select."); + } + + // Set of agents array may not align with previous execution, constrain index to valid range. + if (this._index > agents.Count - 1) + { + this._index = 0; + } + + var agent = agents[this._index]; + + this._index = (this._index + 1) % agents.Count; + + return Task.FromResult(agent); + } +} diff --git a/dotnet/src/Agents/Core/Chat/TerminationStrategy.cs b/dotnet/src/Agents/Core/Chat/TerminationStrategy.cs new file mode 100644 index 000000000000..7e86fbe063a9 --- /dev/null +++ b/dotnet/src/Agents/Core/Chat/TerminationStrategy.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.SemanticKernel.Agents.Chat; + +/// +/// Base strategy class for defining termination criteria for a . +/// +public abstract class TerminationStrategy +{ + /// + /// Restrict number of turns to a reasonable number (99). + /// + public const int DefaultMaximumIterations = 99; + + /// + /// The maximum number of agent interactions for a given chat invocation. + /// Defaults to: . + /// + public int MaximumIterations { get; set; } = DefaultMaximumIterations; + + /// + /// Set to have automatically clear if caller + /// proceeds with invocation subsequent to achieving termination criteria. + /// + public bool AutomaticReset { get; set; } + + /// + /// Set of agents for which this strategy is applicable. If not set, + /// any agent is evaluated. + /// + public IReadOnlyList? Agents { get; set; } + + /// + /// Called to evaluate termination once is evaluated. + /// + protected abstract Task ShouldAgentTerminateAsync(Agent agent, IReadOnlyList history, CancellationToken cancellationToken); + + /// + /// Evaluate the input message and determine if the chat has met its completion criteria. + /// + /// The agent actively interacting with the nexus. + /// The most recent message + /// The to monitor for cancellation requests. The default is . + /// True to terminate chat loop. + public Task ShouldTerminateAsync(Agent agent, IReadOnlyList history, CancellationToken cancellationToken = default) + { + // `Agents` must contain `agent`, if `Agents` not empty. + if ((this.Agents?.Count ?? 0) > 0 && !this.Agents!.Any(a => a.Id == agent.Id)) + { + return Task.FromResult(false); + } + + return this.ShouldAgentTerminateAsync(agent, history, cancellationToken); + } +} diff --git a/dotnet/src/Agents/Core/ChatCompletionAgent.cs b/dotnet/src/Agents/Core/ChatCompletionAgent.cs index 06a9b985db83..98a216daf821 100644 --- a/dotnet/src/Agents/Core/ChatCompletionAgent.cs +++ b/dotnet/src/Agents/Core/ChatCompletionAgent.cs @@ -1,5 +1,4 @@ // Copyright (c) Microsoft. All rights reserved. - using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Threading; diff --git a/dotnet/src/Agents/UnitTests/Agents.UnitTests.csproj b/dotnet/src/Agents/UnitTests/Agents.UnitTests.csproj index b3d5461f426c..a3d047e1bade 100644 --- a/dotnet/src/Agents/UnitTests/Agents.UnitTests.csproj +++ b/dotnet/src/Agents/UnitTests/Agents.UnitTests.csproj @@ -3,7 +3,7 @@ SemanticKernel.Agents.UnitTests SemanticKernel.Agents.UnitTests - net6.0 + net8.0 LatestMajor true false diff --git a/dotnet/src/Agents/UnitTests/Core/AgentGroupChatTests.cs b/dotnet/src/Agents/UnitTests/Core/AgentGroupChatTests.cs new file mode 100644 index 000000000000..48b652491f53 --- /dev/null +++ b/dotnet/src/Agents/UnitTests/Core/AgentGroupChatTests.cs @@ -0,0 +1,221 @@ +// Copyright (c) Microsoft. All rights reserved. +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.Agents.Chat; +using Microsoft.SemanticKernel.ChatCompletion; +using Moq; +using Xunit; + +namespace SemanticKernel.Agents.UnitTests.Core; + +/// +/// Unit testing of . +/// +public class AgentGroupChatTests +{ + /// + /// Verify the default state of . + /// + [Fact] + public void VerifyGroupAgentChatDefaultState() + { + AgentGroupChat chat = new(); + Assert.Empty(chat.Agents); + Assert.NotNull(chat.ExecutionSettings); + Assert.False(chat.IsComplete); + + chat.IsComplete = true; + Assert.True(chat.IsComplete); + } + + /// + /// Verify the management of instances as they join . + /// + [Fact] + public async Task VerifyGroupAgentChatAgentMembershipAsync() + { + Agent agent1 = CreateMockAgent().Object; + Agent agent2 = CreateMockAgent().Object; + Agent agent3 = CreateMockAgent().Object; + Agent agent4 = CreateMockAgent().Object; + + AgentGroupChat chat = new(agent1, agent2); + Assert.Equal(2, chat.Agents.Count); + + chat.AddAgent(agent3); + Assert.Equal(3, chat.Agents.Count); + + var messages = await chat.InvokeAsync(agent4, isJoining: false).ToArrayAsync(); + Assert.Equal(3, chat.Agents.Count); + + messages = await chat.InvokeAsync(agent4).ToArrayAsync(); + Assert.Equal(4, chat.Agents.Count); + } + + /// + /// Verify the management of instances as they join . + /// + [Fact] + public async Task VerifyGroupAgentChatMultiTurnAsync() + { + Agent agent1 = CreateMockAgent().Object; + Agent agent2 = CreateMockAgent().Object; + Agent agent3 = CreateMockAgent().Object; + + AgentGroupChat chat = + new(agent1, agent2, agent3) + { + ExecutionSettings = + new() + { + TerminationStrategy = + { + // This test is designed to take 9 turns. + MaximumIterations = 9, + } + }, + IsComplete = true + }; + + await Assert.ThrowsAsync(() => chat.InvokeAsync(CancellationToken.None).ToArrayAsync().AsTask()); + + chat.ExecutionSettings.TerminationStrategy.AutomaticReset = true; + var messages = await chat.InvokeAsync(CancellationToken.None).ToArrayAsync(); + Assert.Equal(9, messages.Length); + Assert.False(chat.IsComplete); + + for (int index = 0; index < messages.Length; ++index) // Clean-up + { + switch (index % 3) + { + case 0: + Assert.Equal(agent1.Name, messages[index].AuthorName); + break; + case 1: + Assert.Equal(agent2.Name, messages[index].AuthorName); + break; + case 2: + Assert.Equal(agent3.Name, messages[index].AuthorName); + break; + } + } + } + + /// + /// Verify the management of instances as they join . + /// + [Fact] + public async Task VerifyGroupAgentChatFailedSelectionAsync() + { + AgentGroupChat chat = Create3AgentChat(); + + chat.ExecutionSettings = + new() + { + // Strategy that will not select an agent. + SelectionStrategy = new FailedSelectionStrategy(), + TerminationStrategy = + { + // Remove max-limit in order to isolate the target behavior. + MaximumIterations = int.MaxValue + } + }; + + // Remove max-limit in order to isolate the target behavior. + chat.ExecutionSettings.TerminationStrategy.MaximumIterations = int.MaxValue; + + await Assert.ThrowsAsync(() => chat.InvokeAsync().ToArrayAsync().AsTask()); + } + + /// + /// Verify the management of instances as they join . + /// + [Fact] + public async Task VerifyGroupAgentChatMultiTurnTerminationAsync() + { + AgentGroupChat chat = Create3AgentChat(); + + chat.ExecutionSettings = + new() + { + TerminationStrategy = + new TestTerminationStrategy(shouldTerminate: true) + { + // Remove max-limit in order to isolate the target behavior. + MaximumIterations = int.MaxValue + } + }; + + var messages = await chat.InvokeAsync(CancellationToken.None).ToArrayAsync(); + Assert.Single(messages); + Assert.True(chat.IsComplete); + } + + /// + /// Verify the management of instances as they join . + /// + [Fact] + public async Task VerifyGroupAgentChatDiscreteTerminationAsync() + { + Agent agent1 = CreateMockAgent().Object; + + AgentGroupChat chat = + new() + { + ExecutionSettings = + new() + { + TerminationStrategy = + new TestTerminationStrategy(shouldTerminate: true) + { + // Remove max-limit in order to isolate the target behavior. + MaximumIterations = int.MaxValue + } + } + }; + + var messages = await chat.InvokeAsync(agent1).ToArrayAsync(); + Assert.Single(messages); + Assert.True(chat.IsComplete); + } + + private static AgentGroupChat Create3AgentChat() + { + Agent agent1 = CreateMockAgent().Object; + Agent agent2 = CreateMockAgent().Object; + Agent agent3 = CreateMockAgent().Object; + + return new(agent1, agent2, agent3); + } + + private static Mock CreateMockAgent() + { + Mock agent = new(); + + ChatMessageContent[] messages = [new ChatMessageContent(AuthorRole.Assistant, "test")]; + agent.Setup(a => a.InvokeAsync(It.IsAny>(), It.IsAny())).Returns(() => messages.ToAsyncEnumerable()); + + return agent; + } + + private sealed class TestTerminationStrategy(bool shouldTerminate) : TerminationStrategy + { + protected override Task ShouldAgentTerminateAsync(Agent agent, IReadOnlyList history, CancellationToken cancellationToken) + { + return Task.FromResult(shouldTerminate); + } + } + + private sealed class FailedSelectionStrategy : SelectionStrategy + { + public override Task NextAsync(IReadOnlyList agents, IReadOnlyList history, CancellationToken cancellationToken = default) + { + throw new InvalidOperationException(); + } + } +} diff --git a/dotnet/src/Agents/UnitTests/Core/Chat/AgentGroupChatSettingsTests.cs b/dotnet/src/Agents/UnitTests/Core/Chat/AgentGroupChatSettingsTests.cs new file mode 100644 index 000000000000..d17391ee24be --- /dev/null +++ b/dotnet/src/Agents/UnitTests/Core/Chat/AgentGroupChatSettingsTests.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft. All rights reserved. +using Microsoft.SemanticKernel.Agents.Chat; +using Moq; +using Xunit; + +namespace SemanticKernel.Agents.UnitTests.Core.Chat; + +/// +/// Unit testing of . +/// +public class AgentGroupChatSettingsTests +{ + /// + /// Verify default state. + /// + [Fact] + public void VerifyChatExecutionSettingsDefault() + { + AgentGroupChatSettings settings = new(); + Assert.IsType(settings.TerminationStrategy); + Assert.Equal(1, settings.TerminationStrategy.MaximumIterations); + Assert.IsType(settings.SelectionStrategy); + } + + /// + /// Verify accepts for . + /// + [Fact] + public void VerifyChatExecutionContinuationStrategyDefault() + { + Mock strategyMock = new(); + AgentGroupChatSettings settings = + new() + { + TerminationStrategy = strategyMock.Object + }; + + Assert.Equal(strategyMock.Object, settings.TerminationStrategy); + } + + /// + /// Verify accepts for . + /// + [Fact] + public void VerifyChatExecutionSelectionStrategyDefault() + { + Mock strategyMock = new(); + AgentGroupChatSettings settings = + new() + { + SelectionStrategy = strategyMock.Object + }; + + Assert.NotNull(settings.SelectionStrategy); + Assert.Equal(strategyMock.Object, settings.SelectionStrategy); + } +} diff --git a/dotnet/src/Agents/UnitTests/Core/Chat/AggregatorTerminationStrategyTests.cs b/dotnet/src/Agents/UnitTests/Core/Chat/AggregatorTerminationStrategyTests.cs new file mode 100644 index 000000000000..192c3f846ec2 --- /dev/null +++ b/dotnet/src/Agents/UnitTests/Core/Chat/AggregatorTerminationStrategyTests.cs @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft. All rights reserved. +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.Agents.Chat; +using Moq; +using Xunit; + +namespace SemanticKernel.Agents.UnitTests.Core.Chat; + +/// +/// Unit testing of . +/// +public class AggregatorTerminationStrategyTests +{ + /// + /// Verify initial state. + /// + [Fact] + public void VerifyAggregateTerminationStrategyInitialState() + { + AggregatorTerminationStrategy strategy = new(); + Assert.Equal(AggregateTerminationCondition.All, strategy.Condition); + } + + /// + /// Verify evaluation of AggregateTerminationCondition.Any. + /// + [Fact] + public async Task VerifyAggregateTerminationStrategyAnyAsync() + { + TerminationStrategy strategyMockTrue = new MockTerminationStrategy(terminationResult: true); + TerminationStrategy strategyMockFalse = new MockTerminationStrategy(terminationResult: false); + + Mock agentMock = new(); + + await VerifyResultAsync( + expectedResult: true, + agentMock.Object, + new(strategyMockTrue, strategyMockFalse) + { + Condition = AggregateTerminationCondition.Any, + }); + + await VerifyResultAsync( + expectedResult: false, + agentMock.Object, + new(strategyMockFalse, strategyMockFalse) + { + Condition = AggregateTerminationCondition.Any, + }); + + await VerifyResultAsync( + expectedResult: true, + agentMock.Object, + new(strategyMockTrue, strategyMockTrue) + { + Condition = AggregateTerminationCondition.Any, + }); + } + + /// + /// Verify evaluation of AggregateTerminationCondition.All. + /// + [Fact] + public async Task VerifyAggregateTerminationStrategyAllAsync() + { + TerminationStrategy strategyMockTrue = new MockTerminationStrategy(terminationResult: true); + TerminationStrategy strategyMockFalse = new MockTerminationStrategy(terminationResult: false); + + Mock agentMock = new(); + + await VerifyResultAsync( + expectedResult: false, + agentMock.Object, + new(strategyMockTrue, strategyMockFalse) + { + Condition = AggregateTerminationCondition.All, + }); + + await VerifyResultAsync( + expectedResult: false, + agentMock.Object, + new(strategyMockFalse, strategyMockFalse) + { + Condition = AggregateTerminationCondition.All, + }); + + await VerifyResultAsync( + expectedResult: true, + agentMock.Object, + new(strategyMockTrue, strategyMockTrue) + { + Condition = AggregateTerminationCondition.All, + }); + } + + /// + /// Verify evaluation of agent scope evaluation. + /// + [Fact] + public async Task VerifyAggregateTerminationStrategyAgentAsync() + { + TerminationStrategy strategyMockTrue = new MockTerminationStrategy(terminationResult: true); + TerminationStrategy strategyMockFalse = new MockTerminationStrategy(terminationResult: false); + + Mock agentMockA = new(); + Mock agentMockB = new(); + + await VerifyResultAsync( + expectedResult: false, + agentMockB.Object, + new(strategyMockTrue, strategyMockTrue) + { + Agents = new[] { agentMockA.Object }, + Condition = AggregateTerminationCondition.All, + }); + + await VerifyResultAsync( + expectedResult: true, + agentMockB.Object, + new(strategyMockTrue, strategyMockTrue) + { + Agents = new[] { agentMockB.Object }, + Condition = AggregateTerminationCondition.All, + }); + } + + private static async Task VerifyResultAsync(bool expectedResult, Agent agent, AggregatorTerminationStrategy strategyRoot) + { + var result = await strategyRoot.ShouldTerminateAsync(agent, Array.Empty()); + Assert.Equal(expectedResult, result); + } + + /// + /// Less side-effects when mocking protected method. + /// + private sealed class MockTerminationStrategy(bool terminationResult) : TerminationStrategy + { + protected override Task ShouldAgentTerminateAsync(Agent agent, IReadOnlyList history, CancellationToken cancellationToken) + => Task.FromResult(terminationResult); + } +} diff --git a/dotnet/src/Agents/UnitTests/Core/Chat/RegExTerminationStrategyTests.cs b/dotnet/src/Agents/UnitTests/Core/Chat/RegExTerminationStrategyTests.cs new file mode 100644 index 000000000000..08d29c95115e --- /dev/null +++ b/dotnet/src/Agents/UnitTests/Core/Chat/RegExTerminationStrategyTests.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft. All rights reserved. +using System.Threading.Tasks; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.Agents.Chat; +using Microsoft.SemanticKernel.ChatCompletion; +using Moq; +using Xunit; + +namespace SemanticKernel.Agents.UnitTests.Core.Chat; + +/// +/// Unit testing of . +/// +public class RegExTerminationStrategyTests +{ + /// + /// Verify abililty of strategy to match expression. + /// + [Fact] + public async Task VerifyExpressionTerminationStrategyAsync() + { + RegExTerminationStrategy strategy = new("test"); + + await VerifyResultAsync( + expectedResult: false, + new("(?:^|\\W)test(?:$|\\W)"), + content: "fred"); + + await VerifyResultAsync( + expectedResult: true, + new("(?:^|\\W)test(?:$|\\W)"), + content: "this is a test"); + } + + private static async Task VerifyResultAsync(bool expectedResult, RegExTerminationStrategy strategyRoot, string content) + { + ChatMessageContent message = new(AuthorRole.Assistant, content); + Mock agent = new(); + var result = await strategyRoot.ShouldTerminateAsync(agent.Object, new[] { message }); + Assert.Equal(expectedResult, result); + } +} diff --git a/dotnet/src/Agents/UnitTests/Core/Chat/SequentialSelectionStrategyTests.cs b/dotnet/src/Agents/UnitTests/Core/Chat/SequentialSelectionStrategyTests.cs new file mode 100644 index 000000000000..04339a8309e4 --- /dev/null +++ b/dotnet/src/Agents/UnitTests/Core/Chat/SequentialSelectionStrategyTests.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft. All rights reserved. +using System.Threading.Tasks; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.Agents.Chat; +using Moq; +using Xunit; + +namespace SemanticKernel.Agents.UnitTests.Core.Chat; + +/// +/// Unit testing of . +/// +public class SequentialSelectionStrategyTests +{ + /// + /// Verify provides agents in expected order. + /// + [Fact] + public async Task VerifySequentialSelectionStrategyTurnsAsync() + { + Mock agent1 = new(); + Mock agent2 = new(); + + Agent[] agents = [agent1.Object, agent2.Object]; + SequentialSelectionStrategy strategy = new(); + + await VerifyNextAgent(agent1.Object); + await VerifyNextAgent(agent2.Object); + await VerifyNextAgent(agent1.Object); + await VerifyNextAgent(agent2.Object); + await VerifyNextAgent(agent1.Object); + + strategy.Reset(); + await VerifyNextAgent(agent1.Object); + + // Verify index does not exceed current bounds. + agents = [agent1.Object]; + await VerifyNextAgent(agent1.Object); + + async Task VerifyNextAgent(Agent agent1) + { + Agent? nextAgent = await strategy.NextAsync(agents, []); + Assert.NotNull(nextAgent); + Assert.Equal(agent1.Id, nextAgent.Id); + } + } + + /// + /// Verify behavior with no agents. + /// + [Fact] + public async Task VerifySequentialSelectionStrategyEmptyAsync() + { + SequentialSelectionStrategy strategy = new(); + await Assert.ThrowsAsync(() => strategy.NextAsync([], [])); + } +} diff --git a/dotnet/src/Agents/UnitTests/Core/ChatCompletionAgentTests.cs b/dotnet/src/Agents/UnitTests/Core/ChatCompletionAgentTests.cs index fea77fb4b299..5357f0edbd11 100644 --- a/dotnet/src/Agents/UnitTests/Core/ChatCompletionAgentTests.cs +++ b/dotnet/src/Agents/UnitTests/Core/ChatCompletionAgentTests.cs @@ -54,11 +54,15 @@ public async Task VerifyChatCompletionAgentInvocationAsync() var agent = new ChatCompletionAgent() { - Kernel = CreateKernel(mockService.Object) + Instructions = "test instructions", + Kernel = CreateKernel(mockService.Object), + ExecutionSettings = new(), }; var result = await agent.InvokeAsync([]).ToArrayAsync(); + Assert.Single(result); + mockService.Verify( x => x.GetChatMessageContentsAsync(