diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index c458b4fcf6db..d2c1a0016c98 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -7,9 +7,9 @@ - + @@ -31,24 +31,22 @@ + + - - - - - - + + @@ -64,7 +62,11 @@ + + + + @@ -147,8 +149,6 @@ - - diff --git a/dotnet/SK-dotnet.sln b/dotnet/SK-dotnet.sln index eefa32d32d28..1d260a6a33bf 100644 --- a/dotnet/SK-dotnet.sln +++ b/dotnet/SK-dotnet.sln @@ -550,6 +550,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Runtime.InProcess.UnitTests EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VectorData.UnitTests", "src\Connectors\VectorData.UnitTests\VectorData.UnitTests.csproj", "{AAC7B5E8-CC4E-49D0-AF6A-2B4F7B43BD84}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agents.Orchestration", "src\Agents\Orchestration\Agents.Orchestration.csproj", "{D1A02387-FA60-22F8-C2ED-4676568B6CC3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1511,6 +1513,12 @@ Global {AAC7B5E8-CC4E-49D0-AF6A-2B4F7B43BD84}.Publish|Any CPU.Build.0 = Debug|Any CPU {AAC7B5E8-CC4E-49D0-AF6A-2B4F7B43BD84}.Release|Any CPU.ActiveCfg = Release|Any CPU {AAC7B5E8-CC4E-49D0-AF6A-2B4F7B43BD84}.Release|Any CPU.Build.0 = Release|Any CPU + {D1A02387-FA60-22F8-C2ED-4676568B6CC3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D1A02387-FA60-22F8-C2ED-4676568B6CC3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D1A02387-FA60-22F8-C2ED-4676568B6CC3}.Publish|Any CPU.ActiveCfg = Publish|Any CPU + {D1A02387-FA60-22F8-C2ED-4676568B6CC3}.Publish|Any CPU.Build.0 = Publish|Any CPU + {D1A02387-FA60-22F8-C2ED-4676568B6CC3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D1A02387-FA60-22F8-C2ED-4676568B6CC3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1536,7 +1544,7 @@ Global {AFA81EB7-F869-467D-8A90-744305D80AAC} = {1B4CBDE0-10C2-4E7D-9CD0-FE7586C96ED1} {627742DB-1E52-468A-99BD-6FF1A542D25B} = {831DDCA2-7D2C-4C31-80DB-6BDB3E1F7AE0} {E3299033-EB81-4C4C-BCD9-E8DC40937969} = {831DDCA2-7D2C-4C31-80DB-6BDB3E1F7AE0} - {078F96B4-09E1-4E0E-B214-F71A4F4BF633} = {831DDCA2-7D2C-4C31-80DB-6BDB3E1F7AE0} + {078F96B4-09E1-4E0E-B214-F71A4F4BF633} = {9ECD1AA0-75B3-4E25-B0B5-9F0945B64974} {F51017A9-15C8-472D-893C-080046D710A6} = {078F96B4-09E1-4E0E-B214-F71A4F4BF633} {EC3BB6D1-2FB2-4702-84C6-F791DE533ED4} = {24503383-A8C4-4255-9998-28D70FE8E99A} {4D226C2F-AE9F-4EFB-AF2D-45C8FE5CB34E} = {24503383-A8C4-4255-9998-28D70FE8E99A} @@ -1716,6 +1724,7 @@ Global {CCC909E4-5269-A31E-0BFD-4863B4B29BBB} = {A70ED5A7-F8E1-4A57-9455-3C05989542DA} {DA6B4ED4-ED0B-D25C-889C-9F940E714891} = {A70ED5A7-F8E1-4A57-9455-3C05989542DA} {AAC7B5E8-CC4E-49D0-AF6A-2B4F7B43BD84} = {5A7028A7-4DDF-4E4F-84A9-37CE8F8D7E89} + {D1A02387-FA60-22F8-C2ED-4676568B6CC3} = {6823CD5E-2ABE-41EB-B865-F86EC13F0CF9} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {FBDC56A3-86AD-4323-AA0F-201E59123B83} diff --git a/dotnet/nuget.config b/dotnet/nuget.config index 7159fcd04c36..143754718558 100644 --- a/dotnet/nuget.config +++ b/dotnet/nuget.config @@ -1,6 +1,6 @@ - + - + @@ -11,5 +11,5 @@ - + diff --git a/dotnet/samples/GettingStartedWithAgents/GettingStartedWithAgents.csproj b/dotnet/samples/GettingStartedWithAgents/GettingStartedWithAgents.csproj index 90818906f219..555751348dae 100644 --- a/dotnet/samples/GettingStartedWithAgents/GettingStartedWithAgents.csproj +++ b/dotnet/samples/GettingStartedWithAgents/GettingStartedWithAgents.csproj @@ -9,7 +9,7 @@ true - $(NoWarn);CS8618,IDE0009,IDE1006,CA1051,CA1050,CA1707,CA1054,CA2007,VSTHRD111,CS1591,RCS1110,RCS1243,CA5394,SKEXP0001,SKEXP0010,SKEXP0040,SKEXP0050,SKEXP0060,SKEXP0070,SKEXP0101,SKEXP0110,OPENAI001 + $(NoWarn);IDE1006;IDE0009;CS8618;CA1051;CA1050;CA1707;CA1054;CA2007;CA5394;VSTHRD111;CS1591;NU1605;RCS1110;RCS1243;SKEXP0001;SKEXP0010;SKEXP0020;SKEXP0040;SKEXP0050;SKEXP0060;SKEXP0070;SKEXP0101;SKEXP0110;OPENAI001 Library 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 @@ -46,6 +46,8 @@ + + diff --git a/dotnet/samples/GettingStartedWithAgents/Orchestration/Step01_Concurrent.cs b/dotnet/samples/GettingStartedWithAgents/Orchestration/Step01_Concurrent.cs new file mode 100644 index 000000000000..9bf0afc24aea --- /dev/null +++ b/dotnet/samples/GettingStartedWithAgents/Orchestration/Step01_Concurrent.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.Agents.Orchestration; +using Microsoft.SemanticKernel.Agents.Orchestration.Concurrent; +using Microsoft.SemanticKernel.Agents.Runtime.InProcess; + +namespace GettingStarted.Orchestration; + +/// +/// Demonstrates how to use the +/// for executing multiple agents on the same task in parallel. +/// +public class Step01_Concurrent(ITestOutputHelper output) : BaseOrchestrationTest(output) +{ + [Fact] + public async Task ConcurrentTaskAsync() + { + // Define the agents + ChatCompletionAgent physicist = + this.CreateAgent( + instructions: "You are an expert in physics. You answer questions from a physics perspective.", + description: "An expert in physics"); + ChatCompletionAgent chemist = + this.CreateAgent( + instructions: "You are an expert in chemistry. You answer questions from a chemistry perspective.", + description: "An expert in chemistry"); + + // Define the orchestration + OrchestrationMonitor monitor = new(); + ConcurrentOrchestration orchestration = + new(physicist, chemist) + { + ResponseCallback = monitor.ResponseCallback, + LoggerFactory = this.LoggerFactory + }; + + // Start the runtime + InProcessRuntime runtime = new(); + await runtime.StartAsync(); + + // Run the orchestration + string input = "What is temperature?"; + Console.WriteLine($"\n# INPUT: {input}\n"); + OrchestrationResult result = await orchestration.InvokeAsync(input, runtime); + + string[] output = await result.GetValueAsync(TimeSpan.FromSeconds(ResultTimeoutInSeconds)); + Console.WriteLine($"\n# RESULT:\n{string.Join("\n\n", output.Select(text => $"{text}"))}"); + + await runtime.RunUntilIdleAsync(); + + Console.WriteLine("\n\nORCHESTRATION HISTORY"); + foreach (ChatMessageContent message in monitor.History) + { + this.WriteAgentChatMessage(message); + } + } +} diff --git a/dotnet/samples/GettingStartedWithAgents/Orchestration/Step01a_ConcurrentWithStructuredOutput.cs b/dotnet/samples/GettingStartedWithAgents/Orchestration/Step01a_ConcurrentWithStructuredOutput.cs new file mode 100644 index 000000000000..bcb08bb8a7ff --- /dev/null +++ b/dotnet/samples/GettingStartedWithAgents/Orchestration/Step01a_ConcurrentWithStructuredOutput.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.Agents.Orchestration; +using Microsoft.SemanticKernel.Agents.Orchestration.Concurrent; +using Microsoft.SemanticKernel.Agents.Orchestration.Transforms; +using Microsoft.SemanticKernel.Agents.Runtime.InProcess; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.OpenAI; +using Resources; + +namespace GettingStarted.Orchestration; + +/// +/// Demonstrates how to use the with structured output. +/// +public class Step01a_ConcurrentWithStructuredOutput(ITestOutputHelper output) : BaseOrchestrationTest(output) +{ + private static readonly JsonSerializerOptions s_options = new() { WriteIndented = true }; + + [Fact] + public async Task ConcurrentStructuredOutputAsync() + { + // Define the agents + ChatCompletionAgent agent1 = + this.CreateAgent( + instructions: "You are an expert in identifying themes in articles. Given an article, identify the main themes.", + description: "An expert in identifying themes in articles"); + ChatCompletionAgent agent2 = + this.CreateAgent( + instructions: "You are an expert in sentiment analysis. Given an article, identify the sentiment.", + description: "An expert in sentiment analysis"); + ChatCompletionAgent agent3 = + this.CreateAgent( + instructions: "You are an expert in entity recognition. Given an article, extract the entities.", + description: "An expert in entity recognition"); + + // Define the orchestration with transform + Kernel kernel = this.CreateKernelWithChatCompletion(); + StructuredOutputTransform outputTransform = + new(kernel.GetRequiredService(), + new OpenAIPromptExecutionSettings { ResponseFormat = typeof(Analysis) }); + ConcurrentOrchestration orchestration = + new(agent1, agent2, agent3) + { + LoggerFactory = this.LoggerFactory, + ResultTransform = outputTransform.TransformAsync, + }; + + // Start the runtime + InProcessRuntime runtime = new(); + await runtime.StartAsync(); + + // Run the orchestration + const string resourceId = "Hamlet_full_play_summary.txt"; + string input = EmbeddedResource.Read(resourceId); + Console.WriteLine($"\n# INPUT: @{resourceId}\n"); + OrchestrationResult result = await orchestration.InvokeAsync(input, runtime); + + Analysis output = await result.GetValueAsync(TimeSpan.FromSeconds(ResultTimeoutInSeconds * 2)); + Console.WriteLine($"\n# RESULT:\n{JsonSerializer.Serialize(output, s_options)}"); + + await runtime.RunUntilIdleAsync(); + } + + private sealed class Analysis + { + public IList Themes { get; set; } = []; + public IList Sentiments { get; set; } = []; + public IList Entities { get; set; } = []; + } +} diff --git a/dotnet/samples/GettingStartedWithAgents/Orchestration/Step02_Sequential.cs b/dotnet/samples/GettingStartedWithAgents/Orchestration/Step02_Sequential.cs new file mode 100644 index 000000000000..f0e08df86ebb --- /dev/null +++ b/dotnet/samples/GettingStartedWithAgents/Orchestration/Step02_Sequential.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.Agents.Orchestration; +using Microsoft.SemanticKernel.Agents.Orchestration.Sequential; +using Microsoft.SemanticKernel.Agents.Runtime.InProcess; + +namespace GettingStarted.Orchestration; + +/// +/// Demonstrates how to use the for +/// executing multiple agents in sequence, i.e.the output of one agent is +/// the input to the next agent. +/// +public class Step02_Sequential(ITestOutputHelper output) : BaseOrchestrationTest(output) +{ + [Fact] + public async Task SequentialTaskAsync() + { + // Define the agents + ChatCompletionAgent analystAgent = + this.CreateAgent( + name: "Analyst", + instructions: + """ + You are a marketing analyst. Given a product description, identify: + - Key features + - Target audience + - Unique selling points + """, + description: "A agent that extracts key concepts from a product description."); + ChatCompletionAgent writerAgent = + this.CreateAgent( + name: "copywriter", + instructions: + """ + You are a marketing copywriter. Given a block of text describing features, audience, and USPs, + compose a compelling marketing copy (like a newsletter section) that highlights these points. + Output should be short (around 150 words), output just the copy as a single text block. + """, + description: "An agent that writes a marketing copy based on the extracted concepts."); + ChatCompletionAgent editorAgent = + this.CreateAgent( + name: "editor", + instructions: + """ + You are an editor. Given the draft copy, correct grammar, improve clarity, ensure consistent tone, + give format and make it polished. Output the final improved copy as a single text block. + """, + description: "An agent that formats and proofreads the marketing copy."); + + // Define the orchestration + OrchestrationMonitor monitor = new(); + SequentialOrchestration orchestration = + new(analystAgent, writerAgent, editorAgent) + { + ResponseCallback = monitor.ResponseCallback, + LoggerFactory = this.LoggerFactory + }; + + // Start the runtime + InProcessRuntime runtime = new(); + await runtime.StartAsync(); + + // Run the orchestration + string input = "An eco-friendly stainless steel water bottle that keeps drinks cold for 24 hours"; + Console.WriteLine($"\n# INPUT: {input}\n"); + OrchestrationResult result = await orchestration.InvokeAsync(input, runtime); + string text = await result.GetValueAsync(TimeSpan.FromSeconds(ResultTimeoutInSeconds)); + Console.WriteLine($"\n# RESULT: {text}"); + + await runtime.RunUntilIdleAsync(); + + Console.WriteLine("\n\nORCHESTRATION HISTORY"); + foreach (ChatMessageContent message in monitor.History) + { + this.WriteAgentChatMessage(message); + } + } +} diff --git a/dotnet/samples/GettingStartedWithAgents/Orchestration/Step02a_SequentialCancellation.cs b/dotnet/samples/GettingStartedWithAgents/Orchestration/Step02a_SequentialCancellation.cs new file mode 100644 index 000000000000..0c55ae7e4299 --- /dev/null +++ b/dotnet/samples/GettingStartedWithAgents/Orchestration/Step02a_SequentialCancellation.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.Agents.Orchestration; +using Microsoft.SemanticKernel.Agents.Orchestration.Sequential; +using Microsoft.SemanticKernel.Agents.Runtime.InProcess; + +namespace GettingStarted.Orchestration; + +/// +/// Demonstrates how to use cancel a while its running. +/// +public class Step02a_SequentialCancellation(ITestOutputHelper output) : BaseOrchestrationTest(output) +{ + [Fact] + public async Task SequentialCancelledAsync() + { + // Define the agents + ChatCompletionAgent agent = + this.CreateAgent( + """ + If the input message is a number, return the number incremented by one. + """, + description: "A agent that increments numbers."); + + // Define the orchestration + SequentialOrchestration orchestration = new(agent) { LoggerFactory = this.LoggerFactory }; + + // Start the runtime + InProcessRuntime runtime = new(); + await runtime.StartAsync(); + + // Run the orchestration + string input = "42"; + Console.WriteLine($"\n# INPUT: {input}\n"); + + OrchestrationResult result = await orchestration.InvokeAsync(input, runtime); + + result.Cancel(); + await Task.Delay(TimeSpan.FromSeconds(3)); + + try + { + string text = await result.GetValueAsync(TimeSpan.FromSeconds(ResultTimeoutInSeconds)); + Console.WriteLine($"\n# RESULT: {text}"); + } + catch + { + Console.WriteLine("\n# CANCELLED"); + } + + await runtime.RunUntilIdleAsync(); + } +} diff --git a/dotnet/samples/GettingStartedWithAgents/Orchestration/Step03_GroupChat.cs b/dotnet/samples/GettingStartedWithAgents/Orchestration/Step03_GroupChat.cs new file mode 100644 index 000000000000..da078315b861 --- /dev/null +++ b/dotnet/samples/GettingStartedWithAgents/Orchestration/Step03_GroupChat.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.Agents.Orchestration; +using Microsoft.SemanticKernel.Agents.Orchestration.GroupChat; +using Microsoft.SemanticKernel.Agents.Runtime.InProcess; + +namespace GettingStarted.Orchestration; + +/// +/// Demonstrates how to use the ith a default +/// round robin manager for controlling the flow of conversation in a round robin fashion. +/// +/// +/// Think of the group chat manager as a state machine, with the following possible states: +/// - Request for user message +/// - Termination, after which the manager will try to filter a result from the conversation +/// - Continuation, at which the manager will select the next agent to speak. +/// +public class Step03_GroupChat(ITestOutputHelper output) : BaseOrchestrationTest(output) +{ + [Fact] + public async Task GroupChatAsync() + { + // Define the agents + ChatCompletionAgent writer = + this.CreateAgent( + name: "CopyWriter", + description: "A copy writer", + instructions: + """ + You are a copywriter with ten years of experience and are known for brevity and a dry humor. + The goal is to refine and decide on the single best copy as an expert in the field. + Only provide a single proposal per response. + You're laser focused on the goal at hand. + Don't waste time with chit chat. + Consider suggestions when refining an idea. + """); + ChatCompletionAgent editor = + this.CreateAgent( + name: "Reviewer", + description: "An editor.", + instructions: + """ + You are an art director who has opinions about copywriting born of a love for David Ogilvy. + The goal is to determine if 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. + """); + + // Define the orchestration + OrchestrationMonitor monitor = new(); + GroupChatOrchestration orchestration = + new(new RoundRobinGroupChatManager() + { + MaximumInvocationCount = 5 + }, + writer, + editor) + { + ResponseCallback = monitor.ResponseCallback, + LoggerFactory = this.LoggerFactory, + }; + + // Start the runtime + InProcessRuntime runtime = new(); + await runtime.StartAsync(); + + string input = "Create a slogon for a new eletric SUV that is affordable and fun to drive."; + Console.WriteLine($"\n# INPUT: {input}\n"); + OrchestrationResult result = await orchestration.InvokeAsync(input, runtime); + string text = await result.GetValueAsync(TimeSpan.FromSeconds(ResultTimeoutInSeconds * 3)); + Console.WriteLine($"\n# RESULT: {text}"); + + await runtime.RunUntilIdleAsync(); + + Console.WriteLine("\n\nORCHESTRATION HISTORY"); + foreach (ChatMessageContent message in monitor.History) + { + this.WriteAgentChatMessage(message); + } + } +} diff --git a/dotnet/samples/GettingStartedWithAgents/Orchestration/Step03a_GroupChatWithHumanInTheLoop.cs b/dotnet/samples/GettingStartedWithAgents/Orchestration/Step03a_GroupChatWithHumanInTheLoop.cs new file mode 100644 index 000000000000..a10c5b8a25a7 --- /dev/null +++ b/dotnet/samples/GettingStartedWithAgents/Orchestration/Step03a_GroupChatWithHumanInTheLoop.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.Agents.Orchestration; +using Microsoft.SemanticKernel.Agents.Orchestration.GroupChat; +using Microsoft.SemanticKernel.Agents.Runtime.InProcess; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace GettingStarted.Orchestration; + +/// +/// Demonstrates how to use the with human in the loop +/// +public class Step03a_GroupChatWithHumanInTheLoop(ITestOutputHelper output) : BaseOrchestrationTest(output) +{ + [Fact] + public async Task GroupChatWithHumanAsync() + { + // Define the agents + ChatCompletionAgent writer = + this.CreateAgent( + name: "CopyWriter", + description: "A copy writer", + instructions: + """ + You are a copywriter with ten years of experience and are known for brevity and a dry humor. + The goal is to refine and decide on the single best copy as an expert in the field. + Only provide a single proposal per response. + You're laser focused on the goal at hand. + Don't waste time with chit chat. + Consider suggestions when refining an idea. + """); + ChatCompletionAgent editor = + this.CreateAgent( + name: "Reviewer", + description: "An editor.", + instructions: + """ + You are an art director who has opinions about copywriting born of a love for David Ogilvy. + The goal is to determine if 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. + """); + + // Define the orchestration + GroupChatOrchestration orchestration = + new( + new CustomRoundRobinGroupChatManager() + { + MaximumInvocationCount = 5, + InteractiveCallback = () => + { + ChatMessageContent input = new(AuthorRole.User, "I like it"); + Console.WriteLine($"\n# INPUT: {input.Content}\n"); + return ValueTask.FromResult(input); + } + }, + writer, + editor) + { + LoggerFactory = this.LoggerFactory + }; + + // Start the runtime + InProcessRuntime runtime = new(); + await runtime.StartAsync(); + + // Run the orchestration + string input = "Create a slogon for a new eletric SUV that is affordable and fun to drive."; + Console.WriteLine($"\n# INPUT: {input}\n"); + OrchestrationResult result = await orchestration.InvokeAsync(input, runtime); + string text = await result.GetValueAsync(TimeSpan.FromSeconds(ResultTimeoutInSeconds * 3)); + Console.WriteLine($"\n# RESULT: {text}"); + + await runtime.RunUntilIdleAsync(); + } + + /// + /// Define a custom group chat manager that enables user input. + /// + /// + /// User input is achieved by overriding the default round robin manager + /// to allow user input after the reviewer agent's message. + /// + private sealed class CustomRoundRobinGroupChatManager : RoundRobinGroupChatManager + { + public override ValueTask> ShouldRequestUserInput(ChatHistory history, CancellationToken cancellationToken = default) + { + string? lastAgent = history.LastOrDefault()?.AuthorName; + + if (lastAgent is null) + { + return ValueTask.FromResult(new GroupChatManagerResult(false) { Reason = "No agents have spoken yet." }); + } + + if (lastAgent == "Reviewer") + { + return ValueTask.FromResult(new GroupChatManagerResult(true) { Reason = "User input is needed after the reviewer's message." }); + } + + return ValueTask.FromResult(new GroupChatManagerResult(false) { Reason = "User input is not needed until the reviewer's message." }); + } + } +} diff --git a/dotnet/samples/GettingStartedWithAgents/Orchestration/Step03b_GroupChatWithAIManager.cs b/dotnet/samples/GettingStartedWithAgents/Orchestration/Step03b_GroupChatWithAIManager.cs new file mode 100644 index 000000000000..f0eef06f53bb --- /dev/null +++ b/dotnet/samples/GettingStartedWithAgents/Orchestration/Step03b_GroupChatWithAIManager.cs @@ -0,0 +1,208 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.Agents.Orchestration; +using Microsoft.SemanticKernel.Agents.Orchestration.GroupChat; +using Microsoft.SemanticKernel.Agents.Runtime.InProcess; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.OpenAI; + +namespace GettingStarted.Orchestration; + +/// +/// Demonstrates how to use the +/// with a group chat manager that uses a chat completion service to +/// control the flow of the conversation. +/// +public class Step03b_GroupChatWithAIManager(ITestOutputHelper output) : BaseOrchestrationTest(output) +{ + [Fact] + public async Task GroupChatWithAIManagerAsync() + { + // Define the agents + ChatCompletionAgent farmer = + this.CreateAgent( + name: "Farmer", + description: "A rural farmer from Southeast Asia.", + instructions: + """ + You're a farmer from Southeast Asia. + Your life is deeply connected to land and family. + You value tradition and sustainability. + You are in a debate. Feel free to challenge the other participants with respect. + """); + ChatCompletionAgent developer = + this.CreateAgent( + name: "Developer", + description: "An urban software developer from the United States.", + instructions: + """ + You're a software developer from the United States. + Your life is fast-paced and technology-driven. + You value innovation, freedom, and work-life balance. + You are in a debate. Feel free to challenge the other participants with respect. + """); + ChatCompletionAgent teacher = + this.CreateAgent( + name: "Teacher", + description: "A retired history teacher from Eastern Europe", + instructions: + """ + You're a retired history teacher from Eastern Europe. + You bring historical and philosophical perspectives to discussions. + You value legacy, learning, and cultural continuity. + You are in a debate. Feel free to challenge the other participants with respect. + """); + ChatCompletionAgent activist = + this.CreateAgent( + name: "Activist", + description: "A young activist from South America.", + instructions: + """ + You're a young activist from South America. + You focus on social justice, environmental rights, and generational change. + You are in a debate. Feel free to challenge the other participants with respect. + """); + ChatCompletionAgent spiritual = + this.CreateAgent( + name: "SpiritualLeader", + description: "A spiritual leader from the Middle East.", + instructions: + """ + You're a spiritual leader from the Middle East. + You provide insights grounded in religion, morality, and community service. + You are in a debate. Feel free to challenge the other participants with respect. + """); + ChatCompletionAgent artist = + this.CreateAgent( + name: "Artist", + description: "An artist from Africa.", + instructions: + """ + You're an artist from Africa. + You view life through creative expression, storytelling, and collective memory. + You are in a debate. Feel free to challenge the other participants with respect. + """); + ChatCompletionAgent immigrant = + this.CreateAgent( + name: "Immigrant", + description: "An immigrant entrepreneur from Asia living in Canada.", + instructions: + """ + You're an immigrant entrepreneur from Asia living in Canada. + You balance trandition with adaption. + You focus on family success, risk, and opportunity. + You are in a debate. Feel free to challenge the other participants with respect. + """); + ChatCompletionAgent doctor = + this.CreateAgent( + name: "Doctor", + description: "A doctor from Scandinavia.", + instructions: + """ + You're a doctor from Scandinavia. + Your perspective is shaped by public health, equity, and structured societal support. + You are in a debate. Feel free to challenge the other participants with respect. + """); + + // Define the orchestration + const string topic = "What does a good life mean to you personally?"; + Kernel kernel = this.CreateKernelWithChatCompletion(); + GroupChatOrchestration orchestration = + new( + new AIGroupChatManager( + topic, + kernel.GetRequiredService()) + { + MaximumInvocationCount = 5 + }, + farmer, + developer, + teacher, + activist, + spiritual, + artist, + immigrant, + doctor) + { + LoggerFactory = this.LoggerFactory + }; + + // Start the runtime + InProcessRuntime runtime = new(); + await runtime.StartAsync(); + + // Run the orchestration + Console.WriteLine($"\n# INPUT: {topic}\n"); + OrchestrationResult result = await orchestration.InvokeAsync(topic, runtime); + string text = await result.GetValueAsync(TimeSpan.FromSeconds(ResultTimeoutInSeconds * 3)); + Console.WriteLine($"\n# RESULT: {text}"); + + await runtime.RunUntilIdleAsync(); + } + + private sealed class AIGroupChatManager(string topic, IChatCompletionService chatCompletion) : GroupChatManager + { + private static class Prompts + { + public static string Termination(string topic) => + $""" + You are mediator that guides a discussion on the topic of '{topic}'. + You need to determine if the discussion has reached a conclusion. + If you would like to end the discussion, please respond with True. Otherwise, respond with False. + """; + + public static string Selection(string topic, string participants) => + $""" + You are mediator that guides a discussion on the topic of '{topic}'. + You need to select the next participant to speak. + Here are the names and descriptions of the participants: + {participants}\n + Please respond with only the name of the participant you would like to select. + """; + + public static string Filter(string topic) => + $""" + You are mediator that guides a discussion on the topic of '{topic}'. + You have just concluded the discussion. + Please summarize the discussion and provide a closing statement. + """; + } + + /// + public override ValueTask> FilterResults(ChatHistory history, CancellationToken cancellationToken = default) => + this.GetResponseAsync(history, Prompts.Filter(topic), cancellationToken); + + /// + public override ValueTask> SelectNextAgent(ChatHistory history, GroupChatTeam team, CancellationToken cancellationToken = default) => + this.GetResponseAsync(history, Prompts.Selection(topic, team.FormatList()), cancellationToken); + + /// + public override ValueTask> ShouldRequestUserInput(ChatHistory history, CancellationToken cancellationToken = default) => + ValueTask.FromResult(new GroupChatManagerResult(false) { Reason = "The AI group chat manager does not request user input." }); + + /// + public override async ValueTask> ShouldTerminate(ChatHistory history, CancellationToken cancellationToken = default) + { + GroupChatManagerResult result = await base.ShouldTerminate(history, cancellationToken); + if (!result.Value) + { + result = await this.GetResponseAsync(history, Prompts.Termination(topic), cancellationToken); + } + return result; + } + + private async ValueTask> GetResponseAsync(ChatHistory history, string prompt, CancellationToken cancellationToken = default) + { + OpenAIPromptExecutionSettings executionSettings = new() { ResponseFormat = typeof(GroupChatManagerResult) }; + ChatHistory request = [.. history, new ChatMessageContent(AuthorRole.System, prompt)]; + ChatMessageContent response = await chatCompletion.GetChatMessageContentAsync(request, executionSettings, kernel: null, cancellationToken); + string responseText = response.ToString(); + return + JsonSerializer.Deserialize>(responseText) ?? + throw new InvalidOperationException($"Failed to parse response: {responseText}"); + } + } +} diff --git a/dotnet/samples/GettingStartedWithAgents/Orchestration/Step04_Handoff.cs b/dotnet/samples/GettingStartedWithAgents/Orchestration/Step04_Handoff.cs new file mode 100644 index 000000000000..2aa66ee23905 --- /dev/null +++ b/dotnet/samples/GettingStartedWithAgents/Orchestration/Step04_Handoff.cs @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.Agents.Orchestration; +using Microsoft.SemanticKernel.Agents.Orchestration.Handoff; +using Microsoft.SemanticKernel.Agents.Runtime.InProcess; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace GettingStarted.Orchestration; + +/// +/// Demonstrates how to use the that represents +/// a customer support triage system.The orchestration consists of 4 agents, each specialized +/// in a different area of customer support: triage, refunds, order status, and order returns. +/// +public class Step04_Handoff(ITestOutputHelper output) : BaseOrchestrationTest(output) +{ + [Fact] + public async Task OrderSupportAsync() + { + // Define the agents & tools + ChatCompletionAgent triageAgent = + this.CreateAgent( + instructions: "A customer support agent that triages issues.", + name: "TriageAgent", + description: "Handle customer requests."); + ChatCompletionAgent statusAgent = + this.CreateAgent( + name: "OrderStatusAgent", + instructions: "Handle order status requests.", + description: "A customer support agent that checks order status."); + statusAgent.Kernel.Plugins.Add(KernelPluginFactory.CreateFromObject(new OrderStatusPlugin())); + ChatCompletionAgent returnAgent = + this.CreateAgent( + name: "OrderReturnAgent", + instructions: "Handle order return requests.", + description: "A customer support agent that handles order returns."); + returnAgent.Kernel.Plugins.Add(KernelPluginFactory.CreateFromObject(new OrderReturnPlugin())); + ChatCompletionAgent refundAgent = + this.CreateAgent( + name: "OrderRefundAgent", + instructions: "Handle order refund requests.", + description: "A customer support agent that handles order refund."); + refundAgent.Kernel.Plugins.Add(KernelPluginFactory.CreateFromObject(new OrderRefundPlugin())); + + // Define the orchestration + OrchestrationMonitor monitor = new(); + Queue responses = new(); + HandoffOrchestration orchestration = + new(OrchestrationHandoffs + .StartWith(triageAgent) + .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"), + triageAgent, + statusAgent, + returnAgent, + refundAgent) + { + InteractiveCallback = () => + { + string input = responses.Dequeue(); + Console.WriteLine($"\n# INPUT: {input}\n"); + return ValueTask.FromResult(new ChatMessageContent(AuthorRole.User, input)); + }, + ResponseCallback = monitor.ResponseCallback, + LoggerFactory = this.LoggerFactory + }; + + // Start the runtime + InProcessRuntime runtime = new(); + await runtime.StartAsync(); + + // Run the orchestration + string task = "I am a customer that needs help with my orders"; + responses.Enqueue("I'd like to track the status of my order"); + responses.Enqueue("My order ID is 123"); + responses.Enqueue("I want to return another order of mine"); + responses.Enqueue("Order ID 321"); + responses.Enqueue("Broken item"); + responses.Enqueue("No, bye"); + Console.WriteLine($"\n# INPUT:\n{task}\n"); + OrchestrationResult result = await orchestration.InvokeAsync(task, runtime); + + string text = await result.GetValueAsync(TimeSpan.FromSeconds(300)); + Console.WriteLine($"\n# RESULT: {text}"); + + await runtime.RunUntilIdleAsync(); + + Console.WriteLine("\n\nORCHESTRATION HISTORY"); + foreach (ChatMessageContent message in monitor.History) + { + this.WriteAgentChatMessage(message); + } + } + + private sealed class OrderStatusPlugin + { + [KernelFunction] + public string CheckOrderStatus(string orderId) => $"Order {orderId} is shipped and will arrive in 2-3 days."; + } + + private sealed class OrderReturnPlugin + { + [KernelFunction] + public string ProcessReturn(string orderId, string reason) => $"Return for order {orderId} has been processed successfully."; + } + + private sealed class OrderRefundPlugin + { + [KernelFunction] + public string ProcessReturn(string orderId, string reason) => $"Refund for order {orderId} has been processed successfully."; + } +} diff --git a/dotnet/samples/GettingStartedWithAgents/Orchestration/Step04a_HandoffWithStructuredInput.cs b/dotnet/samples/GettingStartedWithAgents/Orchestration/Step04a_HandoffWithStructuredInput.cs new file mode 100644 index 000000000000..596a5431aefc --- /dev/null +++ b/dotnet/samples/GettingStartedWithAgents/Orchestration/Step04a_HandoffWithStructuredInput.cs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.Agents.Orchestration; +using Microsoft.SemanticKernel.Agents.Orchestration.Handoff; +using Microsoft.SemanticKernel.Agents.Runtime.InProcess; + +namespace GettingStarted.Orchestration; + +/// +/// Demonstrates how to use the . +/// +public class Step04a_HandoffWithStructuredInput(ITestOutputHelper output) : BaseOrchestrationTest(output) +{ + [Fact] + public async Task HandoffStructuredInputAsync() + { + // Initialize plugin + GithubPlugin githubPlugin = new(); + KernelPlugin plugin = KernelPluginFactory.CreateFromObject(githubPlugin); + + // Define the agents + ChatCompletionAgent triageAgent = + this.CreateAgent( + instructions: "Given a GitHub issue, triage it.", + name: "TriageAgent", + description: "An agent that triages GitHub issues"); + ChatCompletionAgent pythonAgent = + this.CreateAgent( + instructions: "You are an agent that handles Python related GitHub issues.", + name: "PythonAgent", + description: "An agent that handles Python related issues"); + pythonAgent.Kernel.Plugins.Add(plugin); + ChatCompletionAgent dotnetAgent = + this.CreateAgent( + instructions: "You are an agent that handles .NET related GitHub issues.", + name: "DotNetAgent", + description: "An agent that handles .NET related issues"); + dotnetAgent.Kernel.Plugins.Add(plugin); + + // Define the orchestration + HandoffOrchestration orchestration = + new(OrchestrationHandoffs + .StartWith(triageAgent) + .Add(triageAgent, dotnetAgent, pythonAgent), + triageAgent, + pythonAgent, + dotnetAgent) + { + LoggerFactory = this.LoggerFactory + }; + + GithubIssue input = + new() + { + Id = "12345", + Title = "Bug: SQLite Error 1: 'ambiguous column name:' when including VectorStoreRecordKey in VectorSearchOptions.Filter", + Body = + """ + Describe the bug + When using column names marked as [VectorStoreRecordData(IsFilterable = true)] in VectorSearchOptions.Filter, the query runs correctly. + However, using the column name marked as [VectorStoreRecordKey] in VectorSearchOptions.Filter, the query throws exception 'SQLite Error 1: ambiguous column name: StartUTC'. + To Reproduce + Add a filter for the column marked [VectorStoreRecordKey]. Since that same column exists in both the vec_TestTable and TestTable, the data for both columns cannot be returned. + + Expected behavior + The query should explicitly list the vec_TestTable column names to retrieve and should omit the [VectorStoreRecordKey] column since it will be included in the primary TestTable columns. + + Platform + Microsoft.SemanticKernel.Connectors.Sqlite v1.46.0-preview + + Additional context + Normal DBContext logging shows only normal context queries. Queries run by VectorizedSearchAsync() don't appear in those logs and I could not find a way to enable logging in semantic search so that I could actually see the exact query that is failing. It would have been very useful to see the failing semantic query. + """, + Labels = [] + }; + + // Start the runtime + InProcessRuntime runtime = new(); + await runtime.StartAsync(); + + // Run the orchestration + Console.WriteLine($"\n# INPUT:\n{input.Id}: {input.Title}\n"); + OrchestrationResult result = await orchestration.InvokeAsync(input, runtime); + string text = await result.GetValueAsync(TimeSpan.FromSeconds(ResultTimeoutInSeconds)); + Console.WriteLine($"\n# RESULT: {text}"); + Console.WriteLine($"\n# LABELS: {string.Join(",", githubPlugin.Labels["12345"])}\n"); + + await runtime.RunUntilIdleAsync(); + } + + private sealed class GithubIssue + { + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + [JsonPropertyName("body")] + public string Body { get; set; } = string.Empty; + + [JsonPropertyName("labels")] + public string[] Labels { get; set; } = []; + } + + private sealed class GithubPlugin + { + public Dictionary Labels { get; } = []; + + [KernelFunction] + public void AddLabels(string issueId, params string[] labels) + { + this.Labels[issueId] = labels; + } + } +} diff --git a/dotnet/samples/GettingStartedWithAgents/README.md b/dotnet/samples/GettingStartedWithAgents/README.md index abe365fbe665..eb4aa3bbbff3 100644 --- a/dotnet/samples/GettingStartedWithAgents/README.md +++ b/dotnet/samples/GettingStartedWithAgents/README.md @@ -62,6 +62,20 @@ Example|Description [Step05_BedrockAgent_FileSearch](./BedrockAgent/Step05_BedrockAgent_FileSearch.cs)|How to use file search with a Bedrock agent (i.e. Bedrock knowledge base). [Step06_BedrockAgent_AgentChat](./BedrockAgent/Step06_BedrockAgent_AgentChat.cs)|How to create a conversation between two agents and one of them in a Bedrock agent. +### Orchestration + +Example|Description +---|--- +[Step01_Concurrent](./Orchestration/Step01_Concurrent.cs)|How to use a concurrent orchestration.. +[Step01a_ConcurrentWithStructuredOutput](./Orchestration/Step01a_ConcurrentWithStructuredOutput.cs)|How to use structured output (with concurrent orchestration). +[Step02_Sequential](./Orchestration/Step02_Sequential.cs)|How to use sequential orchestration. +[Step02a_Sequential](./Orchestration/Step02a_Sequential.cs)|How to cancel an orchestration (with sequential orchestration). +[Step03_GroupChat](./Orchestration/Step03_GroupChat.cs)|How to use group-chat orchestration. +[Step03a_GroupChatWithHumanInTheLoop](./Orchestration/Step03a_GroupChatWithHumanInTheLoop.cs)|How to use group-chat orchestration with human in the loop. +[Step03b_GroupChatWithAIManager](./Orchestration/Step03b_GroupChatWithAIManager.cs)|How to use group-chat orchestration with a AI powered group-manager. +[Step04_Handoff](./Orchestration/Step04_Handoff.cs)|How to use handoff orchestration. +[Step04b_HandoffWithStructuredInput](./Orchestration/Step04b_HandoffWithStructuredInput.cs)|How to use structured input (with handoff orchestration). + ## Legacy Agents Support for the OpenAI Assistant API was originally published in `Microsoft.SemanticKernel.Experimental.Agents` package: diff --git a/dotnet/samples/GettingStartedWithAgents/Resources/Hamlet_full_play_summary.txt b/dotnet/samples/GettingStartedWithAgents/Resources/Hamlet_full_play_summary.txt new file mode 100644 index 000000000000..9050a46e660f --- /dev/null +++ b/dotnet/samples/GettingStartedWithAgents/Resources/Hamlet_full_play_summary.txt @@ -0,0 +1,13 @@ +On a dark winter night, a ghost walks the ramparts of Elsinore Castle in Denmark. Discovered first by a pair of watchmen, then by the scholar Horatio, the ghost resembles the recently deceased King Hamlet, whose brother Claudius has inherited the throne and married the king’s widow, Queen Gertrude. When Horatio and the watchmen bring Prince Hamlet, the son of Gertrude and the dead king, to see the ghost, it speaks to him, declaring ominously that it is indeed his father’s spirit, and that he was murdered by none other than Claudius. Ordering Hamlet to seek revenge on the man who usurped his throne and married his wife, the ghost disappears with the dawn. + +Prince Hamlet devotes himself to avenging his father’s death, but, because he is contemplative and thoughtful by nature, he delays, entering into a deep melancholy and even apparent madness. Claudius and Gertrude worry about the prince’s erratic behavior and attempt to discover its cause. They employ a pair of Hamlet’s friends, Rosencrantz and Guildenstern, to watch him. When Polonius, the pompous Lord Chamberlain, suggests that Hamlet may be mad with love for his daughter, Ophelia, Claudius agrees to spy on Hamlet in conversation with the girl. But though Hamlet certainly seems mad, he does not seem to love Ophelia: he orders her to enter a nunnery and declares that he wishes to ban marriages. + +A group of traveling actors comes to Elsinore, and Hamlet seizes upon an idea to test his uncle’s guilt. He will have the players perform a scene closely resembling the sequence by which Hamlet imagines his uncle to have murdered his father, so that if Claudius is guilty, he will surely react. When the moment of the murder arrives in the theater, Claudius leaps up and leaves the room. Hamlet and Horatio agree that this proves his guilt. Hamlet goes to kill Claudius but finds him praying. Since he believes that killing Claudius while in prayer would send Claudius’s soul to heaven, Hamlet considers that it would be an inadequate revenge and decides to wait. Claudius, now frightened of Hamlet’s madness and fearing for his own safety, orders that Hamlet be sent to England at once. + +Hamlet goes to confront his mother, in whose bedchamber Polonius has hidden behind a tapestry. Hearing a noise from behind the tapestry, Hamlet believes the king is hiding there. He draws his sword and stabs through the fabric, killing Polonius. For this crime, he is immediately dispatched to England with Rosencrantz and Guildenstern. However, Claudius’s plan for Hamlet includes more than banishment, as he has given Rosencrantz and Guildenstern sealed orders for the King of England demanding that Hamlet be put to death. + +In the aftermath of her father’s death, Ophelia goes mad with grief and drowns in the river. Polonius’s son, Laertes, who has been staying in France, returns to Denmark in a rage. Claudius convinces him that Hamlet is to blame for his father’s and sister’s deaths. When Horatio and the king receive letters from Hamlet indicating that the prince has returned to Denmark after pirates attacked his ship en route to England, Claudius concocts a plan to use Laertes’ desire for revenge to secure Hamlet’s death. Laertes will fence with Hamlet in innocent sport, but Claudius will poison Laertes’ blade so that if he draws blood, Hamlet will die. As a backup plan, the king decides to poison a goblet, which he will give Hamlet to drink should Hamlet score the first or second hits of the match. Hamlet returns to the vicinity of Elsinore just as Ophelia’s funeral is taking place. Stricken with grief, he attacks Laertes and declares that he had in fact always loved Ophelia. Back at the castle, he tells Horatio that he believes one must be prepared to die, since death can come at any moment. A foolish courtier named Osric arrives on Claudius’s orders to arrange the fencing match between Hamlet and Laertes. + +The sword-fighting begins. Hamlet scores the first hit, but declines to drink from the king’s proffered goblet. Instead, Gertrude takes a drink from it and is swiftly killed by the poison. Laertes succeeds in wounding Hamlet, though Hamlet does not die of the poison immediately. First, Laertes is cut by his own sword’s blade, and, after revealing to Hamlet that Claudius is responsible for the queen’s death, he dies from the blade’s poison. Hamlet then stabs Claudius through with the poisoned sword and forces him to drink down the rest of the poisoned wine. Claudius dies, and Hamlet dies immediately after achieving his revenge. + +At this moment, a Norwegian prince named Fortinbras, who has led an army to Denmark and attacked Poland earlier in the play, enters with ambassadors from England, who report that Rosencrantz and Guildenstern are dead. Fortinbras is stunned by the gruesome sight of the entire royal family lying sprawled on the floor dead. He moves to take power of the kingdom. Horatio, fulfilling Hamlet’s last request, tells him Hamlet’s tragic story. Fortinbras orders that Hamlet be carried away in a manner befitting a fallen soldier. \ No newline at end of file diff --git a/dotnet/src/Agents/Orchestration/AgentActor.cs b/dotnet/src/Agents/Orchestration/AgentActor.cs new file mode 100644 index 000000000000..a67be5f056d1 --- /dev/null +++ b/dotnet/src/Agents/Orchestration/AgentActor.cs @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Runtime; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace Microsoft.SemanticKernel.Agents.Orchestration; + +/// +/// An actor that represents an . +/// +public abstract class AgentActor : OrchestrationActor +{ + private AgentInvokeOptions? _options; + + /// + /// Initializes a new instance of the class. + /// + /// The unique identifier of the agent. + /// The runtime associated with the agent. + /// The orchestration context. + /// An . + /// The logger to use for the actor + protected AgentActor(AgentId id, IAgentRuntime runtime, OrchestrationContext context, Agent agent, ILogger? logger = null) + : base( + id, + runtime, + context, + VerifyDescription(agent), + logger) + { + this.Agent = agent; + } + + /// + /// Gets the associated agent. + /// + protected Agent Agent { get; } + + /// + /// Gets or sets the current conversation thread used during agent communication. + /// + protected AgentThread? Thread { get; set; } + + /// + /// Optionally overridden to create custom invocation options for the agent. + /// + protected virtual AgentInvokeOptions? CreateInvokeOptions() + { + return null; + } + + /// + /// Optionally overridden to introduce customer filtering logic for the response callback. + /// + /// The agent response + /// true if the response should be filtered (hidden) + protected virtual bool ResponseCallbackFilter(ChatMessageContent response) => false; + + /// + /// Deletes the agent thread. + /// + /// A cancellation token that can be used to cancel the operation. + protected async ValueTask DeleteThreadAsync(CancellationToken cancellationToken) + { + if (this.Thread != null) + { + await this.Thread.DeleteAsync(cancellationToken).ConfigureAwait(false); + this.Thread = null; + } + } + + /// + /// Invokes the agent with a single chat message. + /// This method sets the message role to and delegates to the overload accepting multiple messages. + /// + /// The chat message content to send. + /// A cancellation token that can be used to cancel the operation. + /// A task that returns the response . + protected ValueTask InvokeAsync(ChatMessageContent input, CancellationToken cancellationToken) + { + return this.InvokeAsync([input], cancellationToken); + } + + /// + /// Invokes the agent with multiple chat messages. + /// Processes the response items and consolidates the messages into a single . + /// + /// The list of chat messages to send. + /// A cancellation token that can be used to cancel the operation. + /// A task that returns the response . + protected async ValueTask InvokeAsync(IList input, CancellationToken cancellationToken) + { + this.Context.Cancellation.ThrowIfCancellationRequested(); + + AgentResponseItem[] responses = + await this.Agent.InvokeAsync( + input, + this.Thread, + this.GetInvokeOptions(), + cancellationToken).ToArrayAsync(cancellationToken).ConfigureAwait(false); + + AgentResponseItem? firstResponse = responses.FirstOrDefault(); + this.Thread ??= firstResponse?.Thread; + + // The vast majority of responses will be a single message. Responses with multiple messages will have their content merged. + ChatMessageContent response = new(firstResponse?.Message.Role ?? AuthorRole.Assistant, string.Join("\n\n", responses.Select(response => response.Message))) + { + AuthorName = firstResponse?.Message.AuthorName, + }; + + if (this.Context.ResponseCallback is not null && !this.ResponseCallbackFilter(response)) + { + await this.Context.ResponseCallback.Invoke(response).ConfigureAwait(false); + } + + return response; + } + + /// + /// Invokes the agent and streams chat message responses asynchronously. + /// Yields each streaming message as it becomes available. + /// + /// The chat message content to send. + /// A cancellation token that can be used to cancel the stream. + /// An asynchronous stream of responses. + protected async IAsyncEnumerable InvokeStreamingAsync(ChatMessageContent input, [EnumeratorCancellation] CancellationToken cancellationToken) + { + this.Context.Cancellation.ThrowIfCancellationRequested(); + + var responseStream = this.Agent.InvokeStreamingAsync([input], this.Thread, this.GetInvokeOptions(), cancellationToken); + + await foreach (AgentResponseItem response in responseStream.ConfigureAwait(false)) + { + this.Thread ??= response.Thread; + yield return response.Message; + } + } + + private AgentInvokeOptions? GetInvokeOptions() => this._options ??= this.CreateInvokeOptions(); + + private static string VerifyDescription(Agent agent) + { + return agent.Description ?? throw new ArgumentException($"Missing agent description: {agent.Name ?? agent.Id}", nameof(agent)); + } +} diff --git a/dotnet/src/Agents/Orchestration/AgentOrchestration.RequestActor.cs b/dotnet/src/Agents/Orchestration/AgentOrchestration.RequestActor.cs new file mode 100644 index 000000000000..d1a65b1b3b0b --- /dev/null +++ b/dotnet/src/Agents/Orchestration/AgentOrchestration.RequestActor.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Orchestration.Transforms; +using Microsoft.SemanticKernel.Agents.Runtime; +using Microsoft.SemanticKernel.Agents.Runtime.Core; + +namespace Microsoft.SemanticKernel.Agents.Orchestration; + +public abstract partial class AgentOrchestration +{ + /// + /// Actor responsible for receiving final message and transforming it into the output type. + /// + private sealed class RequestActor : OrchestrationActor, IHandle + { + private readonly OrchestrationInputTransform _transform; + private readonly Func, ValueTask> _action; + private readonly TaskCompletionSource _completionSource; + + /// + /// Initializes a new instance of the class. + /// + /// The unique identifier of the agent. + /// The runtime associated with the agent. + /// The orchestration context. + /// A function that transforms an input of type TInput into a source type TSource. + /// Optional TaskCompletionSource to signal orchestration completion. + /// An asynchronous function that processes the resulting source. + /// The logger to use for the actor + public RequestActor( + AgentId id, + IAgentRuntime runtime, + OrchestrationContext context, + OrchestrationInputTransform transform, + TaskCompletionSource completionSource, + Func, ValueTask> action, + ILogger? logger = null) + : base(id, runtime, context, $"{id.Type}_Actor", logger) + { + this._transform = transform; + this._action = action; + this._completionSource = completionSource; + } + + /// + /// Handles the incoming message by transforming the input and executing the corresponding action asynchronously. + /// + /// The input message of type TInput. + /// The context of the message, providing additional details. + /// A ValueTask representing the asynchronous operation. + public async ValueTask HandleAsync(TInput item, MessageContext messageContext) + { + this.Logger.LogOrchestrationRequestInvoke(this.Context.Orchestration, this.Id); + try + { + IEnumerable input = await this._transform.Invoke(item).ConfigureAwait(false); + Task task = this._action.Invoke(input).AsTask(); + this.Logger.LogOrchestrationStart(this.Context.Orchestration, this.Id); + await task.ConfigureAwait(false); + } + catch (Exception exception) when (!exception.IsCriticalException()) + { + // Log exception details and allow orchestration to fail + this.Logger.LogOrchestrationRequestFailure(this.Context.Orchestration, this.Id, exception); + this._completionSource.SetException(exception); + throw; + } + } + } +} diff --git a/dotnet/src/Agents/Orchestration/AgentOrchestration.ResultActor.cs b/dotnet/src/Agents/Orchestration/AgentOrchestration.ResultActor.cs new file mode 100644 index 000000000000..2d7e8bbf37a8 --- /dev/null +++ b/dotnet/src/Agents/Orchestration/AgentOrchestration.ResultActor.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Orchestration.Transforms; +using Microsoft.SemanticKernel.Agents.Runtime; +using Microsoft.SemanticKernel.Agents.Runtime.Core; + +namespace Microsoft.SemanticKernel.Agents.Orchestration; + +public abstract partial class AgentOrchestration +{ + /// + /// Actor responsible for receiving the resultant message, transforming it, and handling further orchestration. + /// + private sealed class ResultActor : OrchestrationActor, IHandle + { + private readonly TaskCompletionSource _completionSource; + private readonly OrchestrationResultTransform _transformResult; + private readonly OrchestrationOutputTransform _transform; + + /// + /// Initializes a new instance of the class. + /// + /// The unique identifier of the agent. + /// The runtime associated with the agent. + /// The orchestration context. + /// A delegate that transforms a TResult instance into a ChatMessageContent. + /// A delegate that transforms a ChatMessageContent into a TOutput instance. + /// Optional TaskCompletionSource to signal orchestration completion. + /// The logger to use for the actor + public ResultActor( + AgentId id, + IAgentRuntime runtime, + OrchestrationContext context, + OrchestrationResultTransform transformResult, + OrchestrationOutputTransform transformOutput, + TaskCompletionSource completionSource, + ILogger>? logger = null) + : base(id, runtime, context, $"{id.Type}_Actor", logger) + { + this._completionSource = completionSource; + this._transformResult = transformResult; + this._transform = transformOutput; + } + + /// + /// Processes the received TResult message by transforming it into a TOutput message. + /// If a CompletionTarget is defined, it sends the transformed message to the corresponding agent. + /// Additionally, it signals completion via the provided TaskCompletionSource if available. + /// + /// The result item to process. + /// The context associated with the message. + /// A ValueTask representing asynchronous operation. + public async ValueTask HandleAsync(TResult item, MessageContext messageContext) + { + this.Logger.LogOrchestrationResultInvoke(this.Context.Orchestration, this.Id); + + try + { + if (!this._completionSource.Task.IsCompleted) + { + IList result = this._transformResult.Invoke(item); + TOutput output = await this._transform.Invoke(result).ConfigureAwait(false); + this._completionSource.TrySetResult(output); + } + } + catch (Exception exception) + { + // Log exception details and fail orchestration as per design. + this.Logger.LogOrchestrationResultFailure(this.Context.Orchestration, this.Id, exception); + this._completionSource.SetException(exception); + throw; + } + } + } +} diff --git a/dotnet/src/Agents/Orchestration/AgentOrchestration.cs b/dotnet/src/Agents/Orchestration/AgentOrchestration.cs new file mode 100644 index 000000000000..994b611a59ac --- /dev/null +++ b/dotnet/src/Agents/Orchestration/AgentOrchestration.cs @@ -0,0 +1,234 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.SemanticKernel.Agents.Orchestration.Extensions; +using Microsoft.SemanticKernel.Agents.Orchestration.Transforms; +using Microsoft.SemanticKernel.Agents.Runtime; + +namespace Microsoft.SemanticKernel.Agents.Orchestration; + +/// +/// Called for every response is produced by any agent. +/// +/// The agent response +public delegate ValueTask OrchestrationResponseCallback(ChatMessageContent response); + +/// +/// Called when human interaction is requested. +/// +public delegate ValueTask OrchestrationInteractiveCallback(); + +/// +/// Base class for multi-agent agent orchestration patterns. +/// +/// The type of the input to the orchestration. +/// The type of the result output by the orchestration. +public abstract partial class AgentOrchestration +{ + /// + /// Initializes a new instance of the class. + /// + /// Specifies the member agents or orchestrations participating in this orchestration. + protected AgentOrchestration(params Agent[] members) + { + // Capture orchestration root name without generic parameters for use in + // agent type and topic formatting as well as logging. + this.OrchestrationLabel = this.GetType().Name.Split('`').First(); + + this.Members = members; + } + + /// + /// Gets the description of the orchestration. + /// + public string Description { get; init; } = string.Empty; + + /// + /// Gets the name of the orchestration. + /// + public string Name { get; init; } = string.Empty; + + /// + /// Gets the associated logger. + /// + public ILoggerFactory LoggerFactory { get; init; } = NullLoggerFactory.Instance; + + /// + /// Transforms the orchestration input into a source input suitable for processing. + /// + public OrchestrationInputTransform InputTransform { get; init; } = DefaultTransforms.FromInput; + + /// + /// Transforms the processed result into the final output form. + /// + public OrchestrationOutputTransform ResultTransform { get; init; } = DefaultTransforms.ToOutput; + + /// + /// Optional callback that is invoked for every agent response. + /// + public OrchestrationResponseCallback? ResponseCallback { get; init; } + + /// + /// Gets the list of member targets involved in the orchestration. + /// + protected IReadOnlyList Members { get; } + + /// + /// Orchestration identifier without generic parameters for use in + /// agent type and topic formatting as well as logging. + /// + protected string OrchestrationLabel { get; } + + /// + /// Initiates processing of the orchestration. + /// + /// The input message. + /// The runtime associated with the orchestration. + /// A cancellation token that can be used to cancel the operation. + public async ValueTask> InvokeAsync( + TInput input, + IAgentRuntime runtime, + CancellationToken cancellationToken = default) + { + Verify.NotNull(input, nameof(input)); + + TopicId topic = new($"{this.OrchestrationLabel}_{Guid.NewGuid().ToString().Replace("-", string.Empty)}"); + + CancellationTokenSource orchestrationCancelSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + OrchestrationContext context = new(this.OrchestrationLabel, topic, this.ResponseCallback, this.LoggerFactory, cancellationToken); + + ILogger logger = this.LoggerFactory.CreateLogger(this.GetType()); + + TaskCompletionSource completion = new(); + + AgentType orchestrationType = await this.RegisterAsync(runtime, context, completion, handoff: null).ConfigureAwait(false); + + cancellationToken.ThrowIfCancellationRequested(); + + logger.LogOrchestrationInvoke(this.OrchestrationLabel, topic); + + Task task = runtime.SendMessageAsync(input, orchestrationType, cancellationToken).AsTask(); + + logger.LogOrchestrationYield(this.OrchestrationLabel, topic); + + return new OrchestrationResult(context, completion, orchestrationCancelSource, logger); + } + + /// + /// Initiates processing according to the orchestration pattern. + /// + /// The runtime associated with the orchestration. + /// The unique identifier for the orchestration session. + /// The input to be transformed and processed. + /// The initial agent type used for starting the orchestration. + protected abstract ValueTask StartAsync(IAgentRuntime runtime, TopicId topic, IEnumerable input, AgentType? entryAgent); + + /// + /// Orchestration specific registration, including members and returns an optional entry agent. + /// + /// The runtime targeted for registration. + /// The orchestration context. + /// A registration context. + /// The logger to use during registration + /// The entry AgentType for the orchestration, if any. + protected abstract ValueTask RegisterOrchestrationAsync(IAgentRuntime runtime, OrchestrationContext context, RegistrationContext registrar, ILogger logger); + + /// + /// Formats and returns a unique AgentType based on the provided topic and suffix. + /// + /// The topic identifier used in formatting the agent type. + /// A suffix to differentiate the agent type. + /// A formatted AgentType object. + protected AgentType FormatAgentType(TopicId topic, string suffix) => new($"{topic.Type}_{suffix}"); + + /// + /// Registers the orchestration's root and boot agents, setting up completion and target routing. + /// + /// The runtime targeted for registration. + /// The orchestration context. + /// A TaskCompletionSource for the orchestration. + /// The actor type used for handoff. Only defined for nested orchestrations. + /// The AgentType representing the orchestration entry point. + private async ValueTask RegisterAsync(IAgentRuntime runtime, OrchestrationContext context, TaskCompletionSource completion, AgentType? handoff) + { + // Create a logger for the orchestration registration. + ILogger logger = context.LoggerFactory.CreateLogger(this.GetType()); + logger.LogOrchestrationRegistrationStart(context.Orchestration, context.Topic); + + // Register orchestration + RegistrationContext registrar = new(this.FormatAgentType(context.Topic, "Root"), runtime, context, completion, this.ResultTransform); + AgentType? entryAgent = await this.RegisterOrchestrationAsync(runtime, context, registrar, logger).ConfigureAwait(false); + + // Register actor for orchestration entry-point + AgentType orchestrationEntry = + await runtime.RegisterAgentFactoryAsync( + this.FormatAgentType(context.Topic, "Boot"), + (agentId, runtime) => + { + RequestActor actor = + new(agentId, + runtime, + context, + this.InputTransform, + completion, + StartAsync, + context.LoggerFactory.CreateLogger()); +#if !NETCOREAPP + return actor.AsValueTask(); +#else + return ValueTask.FromResult(actor); +#endif + }).ConfigureAwait(false); + + logger.LogOrchestrationRegistrationDone(context.Orchestration, context.Topic); + + return orchestrationEntry; + + ValueTask StartAsync(IEnumerable input) => this.StartAsync(runtime, context.Topic, input, entryAgent); + } + + /// + /// A context used during registration (). + /// + public sealed class RegistrationContext( + AgentType agentType, + IAgentRuntime runtime, + OrchestrationContext context, + TaskCompletionSource completion, + OrchestrationOutputTransform outputTransform) + { + /// + /// Register the final result type. + /// + public async ValueTask RegisterResultTypeAsync(OrchestrationResultTransform resultTransform) + { + // Register actor for final result + return + await runtime.RegisterAgentFactoryAsync( + agentType, + (agentId, runtime) => + { + ResultActor actor = + new(agentId, + runtime, + context, + resultTransform, + outputTransform, + completion, + context.LoggerFactory.CreateLogger>()); +#if !NETCOREAPP + return actor.AsValueTask(); +#else + return ValueTask.FromResult(actor); +#endif + }).ConfigureAwait(false); + } + } +} diff --git a/dotnet/src/Agents/Orchestration/Agents.Orchestration.csproj b/dotnet/src/Agents/Orchestration/Agents.Orchestration.csproj new file mode 100644 index 000000000000..d748b3d8457a --- /dev/null +++ b/dotnet/src/Agents/Orchestration/Agents.Orchestration.csproj @@ -0,0 +1,39 @@ + + + + + Microsoft.SemanticKernel.Agents.Orchestration + Microsoft.SemanticKernel.Agents.Orchestration + net8.0;netstandard2.0 + $(NoWarn);SKEXP0110;SKEXP0001 + false + preview + + + + + + + Semantic Kernel Agents - Orchestration + Defines Agent orchestration patterns. + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/src/Agents/Orchestration/Concurrent/ConcurrentActor.cs b/dotnet/src/Agents/Orchestration/Concurrent/ConcurrentActor.cs new file mode 100644 index 000000000000..fc57ecf236e4 --- /dev/null +++ b/dotnet/src/Agents/Orchestration/Concurrent/ConcurrentActor.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Runtime; +using Microsoft.SemanticKernel.Agents.Runtime.Core; + +namespace Microsoft.SemanticKernel.Agents.Orchestration.Concurrent; + +/// +/// An used with the . +/// +internal sealed class ConcurrentActor : AgentActor, IHandle +{ + private readonly AgentType _handoffActor; + + /// + /// Initializes a new instance of the class. + /// + /// The unique identifier of the agent. + /// The runtime associated with the agent. + /// The orchestration context. + /// An . + /// Identifies the actor collecting results. + /// The logger to use for the actor + public ConcurrentActor(AgentId id, IAgentRuntime runtime, OrchestrationContext context, Agent agent, AgentType resultActor, ILogger? logger = null) + : base(id, runtime, context, agent, logger) + { + this._handoffActor = resultActor; + } + + /// + public async ValueTask HandleAsync(ConcurrentMessages.Request item, MessageContext messageContext) + { + this.Logger.LogConcurrentAgentInvoke(this.Id); + + ChatMessageContent response = await this.InvokeAsync(item.Messages, messageContext.CancellationToken).ConfigureAwait(false); + + this.Logger.LogConcurrentAgentResult(this.Id, response.Content); + + await this.SendMessageAsync(response.AsResultMessage(), this._handoffActor, messageContext.CancellationToken).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Agents/Orchestration/Concurrent/ConcurrentMessages.cs b/dotnet/src/Agents/Orchestration/Concurrent/ConcurrentMessages.cs new file mode 100644 index 000000000000..29088f053ce6 --- /dev/null +++ b/dotnet/src/Agents/Orchestration/Concurrent/ConcurrentMessages.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace Microsoft.SemanticKernel.Agents.Orchestration.Concurrent; + +/// +/// Common messages used by the . +/// +internal static class ConcurrentMessages +{ + /// + /// An empty message instance as a default. + /// + public static readonly ChatMessageContent Empty = new(); + + /// + /// The input task for a . + /// + public sealed class Request + { + /// + /// The request input. + /// + public IList Messages { get; init; } = []; + } + + /// + /// A result from a . + /// + public sealed class Result + { + /// + /// The result message. + /// + public ChatMessageContent Message { get; init; } = Empty; + } + + /// + /// Extension method to convert a to a . + /// + public static Result AsResultMessage(this string text, AuthorRole? role = null) => new() { Message = new ChatMessageContent(role ?? AuthorRole.Assistant, text) }; + + /// + /// Extension method to convert a to a . + /// + public static Result AsResultMessage(this ChatMessageContent message) => new() { Message = message }; + + /// + /// Extension method to convert a collection of to a . + /// + public static Request AsInputMessage(this IEnumerable messages) => new() { Messages = [.. messages] }; +} diff --git a/dotnet/src/Agents/Orchestration/Concurrent/ConcurrentOrchestration.String.cs b/dotnet/src/Agents/Orchestration/Concurrent/ConcurrentOrchestration.String.cs new file mode 100644 index 000000000000..bfd3214fc105 --- /dev/null +++ b/dotnet/src/Agents/Orchestration/Concurrent/ConcurrentOrchestration.String.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; + +#if NETCOREAPP +using System.Threading.Tasks; +#endif + +namespace Microsoft.SemanticKernel.Agents.Orchestration.Concurrent; + +/// +/// An orchestration that broadcasts the input message to each agent. +/// +public sealed class ConcurrentOrchestration : ConcurrentOrchestration +{ + /// + /// Initializes a new instance of the class. + /// + /// The agents to be orchestrated. + public ConcurrentOrchestration(params Agent[] members) + : base(members) + { + this.ResultTransform = + (response, cancellationToken) => + { + string[] result = [.. response.Select(r => r.Content ?? string.Empty)]; +#if !NETCOREAPP + return result.AsValueTask(); +#else + return ValueTask.FromResult(result); +#endif + }; + } +} diff --git a/dotnet/src/Agents/Orchestration/Concurrent/ConcurrentOrchestration.cs b/dotnet/src/Agents/Orchestration/Concurrent/ConcurrentOrchestration.cs new file mode 100644 index 000000000000..fead042e1a14 --- /dev/null +++ b/dotnet/src/Agents/Orchestration/Concurrent/ConcurrentOrchestration.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Orchestration.Extensions; +using Microsoft.SemanticKernel.Agents.Runtime; + +namespace Microsoft.SemanticKernel.Agents.Orchestration.Concurrent; + +/// +/// An orchestration that broadcasts the input message to each agent. +/// +/// +/// TOutput must be an array type for . +/// +public class ConcurrentOrchestration + : AgentOrchestration +{ + /// + /// Initializes a new instance of the class. + /// + /// The agents participating in the orchestration. + public ConcurrentOrchestration(params Agent[] agents) + : base(agents) + { + } + + /// + protected override ValueTask StartAsync(IAgentRuntime runtime, TopicId topic, IEnumerable input, AgentType? entryAgent) + { + return runtime.PublishMessageAsync(input.AsInputMessage(), topic); + } + + /// + protected override async ValueTask RegisterOrchestrationAsync(IAgentRuntime runtime, OrchestrationContext context, RegistrationContext registrar, ILogger logger) + { + AgentType outputType = await registrar.RegisterResultTypeAsync(response => [.. response.Select(r => r.Message)]).ConfigureAwait(false); + + // Register result actor + AgentType resultType = this.FormatAgentType(context.Topic, "Results"); + await runtime.RegisterAgentFactoryAsync( + resultType, + (agentId, runtime) => + { + ConcurrentResultActor actor = new(agentId, runtime, context, outputType, this.Members.Count, context.LoggerFactory.CreateLogger()); +#if !NETCOREAPP + return actor.AsValueTask(); +#else + return ValueTask.FromResult(actor); +#endif + }).ConfigureAwait(false); + logger.LogRegisterActor(this.OrchestrationLabel, resultType, "RESULTS"); + + // Register member actors - All agents respond to the same message. + int agentCount = 0; + foreach (Agent agent in this.Members) + { + ++agentCount; + + AgentType agentType = + await runtime.RegisterAgentFactoryAsync( + this.FormatAgentType(context.Topic, $"Agent_{agentCount}"), + (agentId, runtime) => + { + ConcurrentActor actor = new(agentId, runtime, context, agent, resultType, context.LoggerFactory.CreateLogger()); +#if !NETCOREAPP + return actor.AsValueTask(); +#else + return ValueTask.FromResult(actor); +#endif + }).ConfigureAwait(false); + + logger.LogRegisterActor(this.OrchestrationLabel, agentType, "MEMBER", agentCount); + + await runtime.SubscribeAsync(agentType, context.Topic).ConfigureAwait(false); + } + + return null; + } +} diff --git a/dotnet/src/Agents/Orchestration/Concurrent/ConcurrentResultActor.cs b/dotnet/src/Agents/Orchestration/Concurrent/ConcurrentResultActor.cs new file mode 100644 index 000000000000..eb4e1f2994fe --- /dev/null +++ b/dotnet/src/Agents/Orchestration/Concurrent/ConcurrentResultActor.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Runtime; +using Microsoft.SemanticKernel.Agents.Runtime.Core; + +namespace Microsoft.SemanticKernel.Agents.Orchestration.Concurrent; + +/// +/// Actor for capturing each message. +/// +internal sealed class ConcurrentResultActor : + OrchestrationActor, + IHandle +{ + private readonly ConcurrentQueue _results; + private readonly AgentType _orchestrationType; + private readonly int _expectedCount; + private int _resultCount; + + /// + /// Initializes a new instance of the class. + /// + /// The unique identifier of the agent. + /// The runtime associated with the agent. + /// The orchestration context. + /// Identifies the orchestration agent. + /// The expected number of messages to be received. + /// The logger to use for the actor + public ConcurrentResultActor( + AgentId id, + IAgentRuntime runtime, + OrchestrationContext context, + AgentType orchestrationType, + int expectedCount, + ILogger logger) + : base(id, runtime, context, "Captures the results of the ConcurrentOrchestration", logger) + { + this._orchestrationType = orchestrationType; + this._expectedCount = expectedCount; + this._results = []; + } + + /// + public async ValueTask HandleAsync(ConcurrentMessages.Result item, MessageContext messageContext) + { + this.Logger.LogConcurrentResultCapture(this.Id, this._resultCount + 1, this._expectedCount); + + this._results.Enqueue(item); + + if (Interlocked.Increment(ref this._resultCount) == this._expectedCount) + { + await this.SendMessageAsync(this._results.ToArray(), this._orchestrationType, messageContext.CancellationToken).ConfigureAwait(false); + } + } +} diff --git a/dotnet/src/Agents/Orchestration/Extensions/RuntimeExtensions.cs b/dotnet/src/Agents/Orchestration/Extensions/RuntimeExtensions.cs new file mode 100644 index 000000000000..033dd1e1059c --- /dev/null +++ b/dotnet/src/Agents/Orchestration/Extensions/RuntimeExtensions.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.SemanticKernel.Agents.Runtime; +using Microsoft.SemanticKernel.Agents.Runtime.Core; + +namespace Microsoft.SemanticKernel.Agents.Orchestration.Extensions; + +/// +/// Extension methods for . +/// +public static class RuntimeExtensions +{ + /// + /// Sends a message to the specified agent. + /// + internal static async ValueTask SendMessageAsync(this IAgentRuntime runtime, object message, AgentType agentType, CancellationToken cancellationToken = default) + { + AgentId? agentId = await runtime.GetAgentAsync(agentType, lazy: false).ConfigureAwait(false); + if (agentId.HasValue) + { + await runtime.SendMessageAsync(message, agentId.Value, sender: null, messageId: null, cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Subscribes the specified agent type to the provided topics. + /// + /// The runtime for managing the subscription. + /// The agent type to subscribe. + /// A variable list of topics for subscription. + public static async Task SubscribeAsync(this IAgentRuntime runtime, string agentType, params TopicId[] topics) + { + for (int index = 0; index < topics.Length; ++index) + { + await runtime.AddSubscriptionAsync(new TypeSubscription(topics[index].Type, agentType)).ConfigureAwait(false); + } + } +} diff --git a/dotnet/src/Agents/Orchestration/GroupChat/GroupChatAgentActor.cs b/dotnet/src/Agents/Orchestration/GroupChat/GroupChatAgentActor.cs new file mode 100644 index 000000000000..207702d82b4c --- /dev/null +++ b/dotnet/src/Agents/Orchestration/GroupChat/GroupChatAgentActor.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Runtime; +using Microsoft.SemanticKernel.Agents.Runtime.Core; + +namespace Microsoft.SemanticKernel.Agents.Orchestration.GroupChat; + +/// +/// An used with the . +/// +internal sealed class GroupChatAgentActor : + AgentActor, + IHandle, + IHandle, + IHandle +{ + private readonly List _cache; + + /// + /// Initializes a new instance of the class. + /// + /// The unique identifier of the agent. + /// The runtime associated with the agent. + /// The orchestration context. + /// An . + /// The logger to use for the actor + public GroupChatAgentActor(AgentId id, IAgentRuntime runtime, OrchestrationContext context, Agent agent, ILogger? logger = null) + : base(id, runtime, context, agent, logger) + { + this._cache = []; + } + + /// + public ValueTask HandleAsync(GroupChatMessages.Group item, MessageContext messageContext) + { + this._cache.AddRange(item.Messages); + +#if !NETCOREAPP + return Task.CompletedTask.AsValueTask(); +#else + return ValueTask.CompletedTask; +#endif + } + + /// + public async ValueTask HandleAsync(GroupChatMessages.Reset item, MessageContext messageContext) + { + await this.DeleteThreadAsync(messageContext.CancellationToken).ConfigureAwait(false); + } + + /// + public async ValueTask HandleAsync(GroupChatMessages.Speak item, MessageContext messageContext) + { + this.Logger.LogChatAgentInvoke(this.Id); + + ChatMessageContent response = await this.InvokeAsync(this._cache, messageContext.CancellationToken).ConfigureAwait(false); + + this.Logger.LogChatAgentResult(this.Id, response.Content); + + this._cache.Clear(); + await this.PublishMessageAsync(response.AsGroupMessage(), this.Context.Topic).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Agents/Orchestration/GroupChat/GroupChatManager.cs b/dotnet/src/Agents/Orchestration/GroupChat/GroupChatManager.cs new file mode 100644 index 000000000000..b65f05f48d61 --- /dev/null +++ b/dotnet/src/Agents/Orchestration/GroupChat/GroupChatManager.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace Microsoft.SemanticKernel.Agents.Orchestration.GroupChat; + +/// +/// Represents the result of a group chat manager operation, including a value and a reason. +/// +/// The type of the value returned by the operation. +/// The value returned by the operation. +public sealed class GroupChatManagerResult(TValue value) +{ + /// + /// The reason for the result, providing additional context or explanation. + /// + public string Reason { get; init; } = string.Empty; + + /// + /// The value returned by the group chat manager operation. + /// + public TValue Value { get; } = value; +} + +/// +/// A manager that manages the flow of a group chat. +/// +public abstract class GroupChatManager +{ + private int _invocationCount; + + /// + /// Initializes a new instance of the class. + /// + protected GroupChatManager() { } + + /// + /// Gets the number of times the group chat manager has been invoked. + /// + public int InvocationCount => this._invocationCount; + + /// + /// Gets or sets the maximum number of invocations allowed for the group chat manager. + /// + public int MaximumInvocationCount { get; init; } = int.MaxValue; + + /// + /// Gets or sets the callback to be invoked for interactive input. + /// + public OrchestrationInteractiveCallback? InteractiveCallback { get; init; } + + /// + /// Filters the results of the group chat based on the provided chat history. + /// + /// The chat history to filter. + /// A cancellation token that can be used to cancel the operation. + /// A containing the filtered result as a string. + public abstract ValueTask> FilterResults(ChatHistory history, CancellationToken cancellationToken = default); + + /// + /// Selects the next agent to participate in the group chat based on the provided chat history and team. + /// + /// The chat history to consider. + /// The group of agents participating in the chat. + /// A cancellation token that can be used to cancel the operation. + /// A containing the identifier of the next agent as a string. + public abstract ValueTask> SelectNextAgent(ChatHistory history, GroupChatTeam team, CancellationToken cancellationToken = default); + + /// + /// Determines whether user input should be requested based on the provided chat history. + /// + /// The chat history to consider. + /// A cancellation token that can be used to cancel the operation. + /// A indicating whether user input should be requested. + public abstract ValueTask> ShouldRequestUserInput(ChatHistory history, CancellationToken cancellationToken = default); + + /// + /// Determines whether the group chat should be terminated based on the provided chat history and invocation count. + /// + /// The chat history to consider. + /// A cancellation token that can be used to cancel the operation. + /// A indicating whether the chat should be terminated. + public virtual ValueTask> ShouldTerminate(ChatHistory 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) + { + resultValue = true; + reason = "Maximum number of invocations reached."; + } + + GroupChatManagerResult result = new(resultValue) { Reason = reason }; + +#if !NETCOREAPP + return result.AsValueTask(); +#else + return ValueTask.FromResult(result); +#endif + } +} diff --git a/dotnet/src/Agents/Orchestration/GroupChat/GroupChatManagerActor.cs b/dotnet/src/Agents/Orchestration/GroupChat/GroupChatManagerActor.cs new file mode 100644 index 000000000000..cff379ded649 --- /dev/null +++ b/dotnet/src/Agents/Orchestration/GroupChat/GroupChatManagerActor.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Runtime; +using Microsoft.SemanticKernel.Agents.Runtime.Core; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace Microsoft.SemanticKernel.Agents.Orchestration.GroupChat; + +/// +/// An used to manage a . +/// +internal sealed class GroupChatManagerActor : + OrchestrationActor, + IHandle, + IHandle +{ + /// + /// A common description for the manager. + /// + public const string DefaultDescription = "Orchestrates a team of agents to accomplish a defined task."; + + private readonly AgentType _orchestrationType; + private readonly GroupChatManager _manager; + private readonly ChatHistory _chat; + private readonly GroupChatTeam _team; + + /// + /// Initializes a new instance of the class. + /// + /// The unique identifier of the agent. + /// The runtime associated with the agent. + /// The orchestration context. + /// The manages the flow of the group-chat. + /// The team of agents being orchestrated + /// Identifies the orchestration agent. + /// The logger to use for the actor + public GroupChatManagerActor(AgentId id, IAgentRuntime runtime, OrchestrationContext context, GroupChatManager manager, GroupChatTeam team, AgentType orchestrationType, ILogger? logger = null) + : base(id, runtime, context, DefaultDescription, logger) + { + this._chat = []; + this._manager = manager; + this._orchestrationType = orchestrationType; + this._team = team; + } + + /// + public async ValueTask HandleAsync(GroupChatMessages.InputTask item, MessageContext messageContext) + { + this.Logger.LogChatManagerInit(this.Id); + + this._chat.AddRange(item.Messages); + + await this.PublishMessageAsync(item.Messages.AsGroupMessage(), this.Context.Topic).ConfigureAwait(false); + + await this.ManageAsync(messageContext).ConfigureAwait(false); + } + + /// + public async ValueTask HandleAsync(GroupChatMessages.Group item, MessageContext messageContext) + { + this.Logger.LogChatManagerInvoke(this.Id); + + this._chat.AddRange(item.Messages); + + await this.ManageAsync(messageContext).ConfigureAwait(false); + } + + private async ValueTask ManageAsync(MessageContext messageContext) + { + if (this._manager.InteractiveCallback != null) + { + GroupChatManagerResult inputResult = await this._manager.ShouldRequestUserInput(this._chat, messageContext.CancellationToken).ConfigureAwait(false); + this.Logger.LogChatManagerInput(this.Id, inputResult.Value, inputResult.Reason); + if (inputResult.Value) + { + ChatMessageContent input = await this._manager.InteractiveCallback.Invoke().ConfigureAwait(false); + this.Logger.LogChatManagerUserInput(this.Id, input.Content); + this._chat.Add(input); + await this.PublishMessageAsync(input.AsGroupMessage(), this.Context.Topic).ConfigureAwait(false); + } + } + + GroupChatManagerResult terminateResult = await this._manager.ShouldTerminate(this._chat, messageContext.CancellationToken).ConfigureAwait(false); + this.Logger.LogChatManagerTerminate(this.Id, terminateResult.Value, terminateResult.Reason); + if (terminateResult.Value) + { + GroupChatManagerResult filterResult = await this._manager.FilterResults(this._chat, messageContext.CancellationToken).ConfigureAwait(false); + this.Logger.LogChatManagerResult(this.Id, filterResult.Value, filterResult.Reason); + await this.SendMessageAsync(filterResult.Value.AsResultMessage(), this._orchestrationType, messageContext.CancellationToken).ConfigureAwait(false); + return; + } + + GroupChatManagerResult selectionResult = await this._manager.SelectNextAgent(this._chat, this._team, messageContext.CancellationToken).ConfigureAwait(false); + AgentType selectionType = this._team[selectionResult.Value].Type; + this.Logger.LogChatManagerSelect(this.Id, selectionType); + await this.SendMessageAsync(new GroupChatMessages.Speak(), selectionType, messageContext.CancellationToken).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Agents/Orchestration/GroupChat/GroupChatMessages.cs b/dotnet/src/Agents/Orchestration/GroupChat/GroupChatMessages.cs new file mode 100644 index 000000000000..aaf084b700c9 --- /dev/null +++ b/dotnet/src/Agents/Orchestration/GroupChat/GroupChatMessages.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace Microsoft.SemanticKernel.Agents.Orchestration.GroupChat; + +/// +/// Common messages used for agent chat patterns. +/// +public static class GroupChatMessages +{ + /// + /// An empty message instance as a default. + /// + internal static readonly ChatMessageContent Empty = new(); + + /// + /// Broadcast a message to all . + /// + public sealed class Group + { + /// + /// The chat message being broadcast. + /// + public IEnumerable Messages { get; init; } = []; + } + + /// + /// Reset/clear the conversation history for all . + /// + public sealed class Reset; + + /// + /// The final result. + /// + public sealed class Result + { + /// + /// The chat response message. + /// + public ChatMessageContent Message { get; init; } = Empty; + } + + /// + /// Signal a to respond. + /// + public sealed class Speak; + + /// + /// The input task. + /// + public sealed class InputTask + { + /// + /// A task that does not require any action. + /// + public static readonly InputTask None = new(); + + /// + /// The input that defines the task goal. + /// + public IEnumerable Messages { get; init; } = []; + } + + /// + /// Extension method to convert a to a . + /// + public static Group AsGroupMessage(this ChatMessageContent message) => new() { Messages = [message] }; + + /// + /// Extension method to convert a to a . + /// + public static Group AsGroupMessage(this IEnumerable messages) => new() { Messages = messages }; + + /// + /// Extension method to convert a to a . + /// + public static InputTask AsInputTaskMessage(this IEnumerable messages) => new() { Messages = messages }; + + /// + /// Extension method to convert a to a . + /// + public static Result AsResultMessage(this string text) => new() { Message = new(AuthorRole.Assistant, text) }; +} diff --git a/dotnet/src/Agents/Orchestration/GroupChat/GroupChatOrchestration.String.cs b/dotnet/src/Agents/Orchestration/GroupChat/GroupChatOrchestration.String.cs new file mode 100644 index 000000000000..ca7dc7c9ff90 --- /dev/null +++ b/dotnet/src/Agents/Orchestration/GroupChat/GroupChatOrchestration.String.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.SemanticKernel.Agents.Orchestration.GroupChat; + +/// +/// An orchestration that broadcasts the input message to each agent. +/// +public sealed class GroupChatOrchestration : GroupChatOrchestration +{ + /// + /// Initializes a new instance of the class. + /// + /// The manages the flow of the group-chat. + /// The agents to be orchestrated. + public GroupChatOrchestration(GroupChatManager manager, params Agent[] members) + : base(manager, members) + { + } +} diff --git a/dotnet/src/Agents/Orchestration/GroupChat/GroupChatOrchestration.cs b/dotnet/src/Agents/Orchestration/GroupChat/GroupChatOrchestration.cs new file mode 100644 index 000000000000..d2ed007d62d1 --- /dev/null +++ b/dotnet/src/Agents/Orchestration/GroupChat/GroupChatOrchestration.cs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Orchestration.Extensions; +using Microsoft.SemanticKernel.Agents.Runtime; + +namespace Microsoft.SemanticKernel.Agents.Orchestration.GroupChat; + +/// +/// An orchestration that coordinates a group-chat. +/// +public class GroupChatOrchestration : + AgentOrchestration +{ + internal const string DefaultAgentDescription = "A helpful agent."; + + private readonly GroupChatManager _manager; + + /// + /// Initializes a new instance of the class. + /// + /// The manages the flow of the group-chat. + /// The agents participating in the orchestration. + public GroupChatOrchestration(GroupChatManager manager, params Agent[] agents) + : base(agents) + { + Verify.NotNull(manager, nameof(manager)); + + this._manager = manager; + } + + /// + protected override ValueTask StartAsync(IAgentRuntime runtime, TopicId topic, IEnumerable input, AgentType? entryAgent) + { + if (!entryAgent.HasValue) + { + throw new ArgumentException("Entry agent is not defined.", nameof(entryAgent)); + } + return runtime.SendMessageAsync(input.AsInputTaskMessage(), entryAgent.Value); + } + + /// + protected override async ValueTask RegisterOrchestrationAsync(IAgentRuntime runtime, OrchestrationContext context, RegistrationContext registrar, ILogger logger) + { + AgentType outputType = await registrar.RegisterResultTypeAsync(response => [response.Message]).ConfigureAwait(false); + + int agentCount = 0; + GroupChatTeam team = []; + foreach (Agent agent in this.Members) + { + ++agentCount; + AgentType agentType = await RegisterAgentAsync(agent, agentCount).ConfigureAwait(false); + string name = agent.Name ?? agent.Id ?? agentType; + string? description = agent.Description; + + team[name] = (agentType, description ?? DefaultAgentDescription); + + logger.LogRegisterActor(this.OrchestrationLabel, agentType, "MEMBER", agentCount); + + await runtime.SubscribeAsync(agentType, context.Topic).ConfigureAwait(false); + } + + AgentType managerType = + await runtime.RegisterAgentFactoryAsync( + this.FormatAgentType(context.Topic, "Manager"), + (agentId, runtime) => + { + GroupChatManagerActor actor = new(agentId, runtime, context, this._manager, team, outputType, context.LoggerFactory.CreateLogger()); +#if !NETCOREAPP + return actor.AsValueTask(); +#else + return ValueTask.FromResult(actor); +#endif + }).ConfigureAwait(false); + logger.LogRegisterActor(this.OrchestrationLabel, managerType, "MANAGER"); + + await runtime.SubscribeAsync(managerType, context.Topic).ConfigureAwait(false); + + return managerType; + + ValueTask RegisterAgentAsync(Agent agent, int agentCount) => + runtime.RegisterAgentFactoryAsync( + this.FormatAgentType(context.Topic, $"Agent_{agentCount}"), + (agentId, runtime) => + { + GroupChatAgentActor actor = new(agentId, runtime, context, agent, context.LoggerFactory.CreateLogger()); +#if !NETCOREAPP + return actor.AsValueTask(); +#else + return ValueTask.FromResult(actor); +#endif + }); + } +} diff --git a/dotnet/src/Agents/Orchestration/GroupChat/GroupChatTeam.cs b/dotnet/src/Agents/Orchestration/GroupChat/GroupChatTeam.cs new file mode 100644 index 000000000000..1870d68ce489 --- /dev/null +++ b/dotnet/src/Agents/Orchestration/GroupChat/GroupChatTeam.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.SemanticKernel.Agents.Orchestration.GroupChat; + +/// +/// Describes a team of agents participating in a group chat. +/// +public class GroupChatTeam : Dictionary; + +/// +/// Extensions for . +/// +public static class ChatGroupExtensions +{ + /// + /// Format the names of the agents in the team as a comma delimimted list. + /// + /// The agent team + /// A comma delimimted list of agent name. + public static string FormatNames(this GroupChatTeam team) => string.Join(",", team.Select(t => t.Key)); + + /// + /// Format the names and descriptions of the agents in the team as a markdown list. + /// + /// The agent team + /// A markdown list of agent names and descriptions. + public static string FormatList(this GroupChatTeam team) => string.Join("\n", team.Select(t => $"- {t.Key}: {t.Value.Description}")); +} diff --git a/dotnet/src/Agents/Orchestration/GroupChat/RoundRobinGroupChatManager.cs b/dotnet/src/Agents/Orchestration/GroupChat/RoundRobinGroupChatManager.cs new file mode 100644 index 000000000000..bfd92a858449 --- /dev/null +++ b/dotnet/src/Agents/Orchestration/GroupChat/RoundRobinGroupChatManager.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace Microsoft.SemanticKernel.Agents.Orchestration.GroupChat; + +/// +/// A that selects agents in a round-robin fashion. +/// +/// +/// Subclass this class to customize filter and user interaction behavior. +/// +public class RoundRobinGroupChatManager : GroupChatManager +{ + private int _currentAgentIndex; + + /// + public override ValueTask> FilterResults(ChatHistory history, CancellationToken cancellationToken = default) + { + GroupChatManagerResult result = new(history.LastOrDefault()?.Content ?? string.Empty) { Reason = "Default result filter provides the final chat message." }; +#if !NETCOREAPP + return result.AsValueTask(); +#else + return ValueTask.FromResult(result); +#endif + } + + /// + public override ValueTask> SelectNextAgent(ChatHistory history, GroupChatTeam team, CancellationToken cancellationToken = default) + { + string nextAgent = team.Skip(this._currentAgentIndex).First().Key; + this._currentAgentIndex = (this._currentAgentIndex + 1) % team.Count; + GroupChatManagerResult result = new(nextAgent) { Reason = $"Selected agent at index: {this._currentAgentIndex}" }; +#if !NETCOREAPP + return result.AsValueTask(); +#else + return ValueTask.FromResult(result); +#endif + } + + /// + public override ValueTask> ShouldRequestUserInput(ChatHistory history, CancellationToken cancellationToken = default) + { + GroupChatManagerResult result = new(false) { Reason = "The default round-robin group chat manager does not request user input." }; +#if !NETCOREAPP + return result.AsValueTask(); +#else + return ValueTask.FromResult(result); +#endif + } +} diff --git a/dotnet/src/Agents/Orchestration/Handoff/HandoffActor.cs b/dotnet/src/Agents/Orchestration/Handoff/HandoffActor.cs new file mode 100644 index 000000000000..5fd9e1ff268b --- /dev/null +++ b/dotnet/src/Agents/Orchestration/Handoff/HandoffActor.cs @@ -0,0 +1,187 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Runtime; +using Microsoft.SemanticKernel.Agents.Runtime.Core; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace Microsoft.SemanticKernel.Agents.Orchestration.Handoff; + +/// +/// An actor used with the . +/// +internal sealed class HandoffActor : + AgentActor, + IHandle, + IHandle, + IHandle +{ + private readonly HandoffLookup _handoffs; + private readonly AgentType _resultHandoff; + private readonly List _cache; + + private string? _handoffAgent; + private string? _taskSummary; + + /// + /// Initializes a new instance of the class. + /// + /// The unique identifier of the agent. + /// The runtime associated with the agent. + /// The orchestration context. + /// An . + /// The handoffs available to this agent + /// The handoff agent for capturing the result. + /// The logger to use for the actor + public HandoffActor(AgentId id, IAgentRuntime runtime, OrchestrationContext context, Agent agent, HandoffLookup handoffs, AgentType resultHandoff, ILogger? logger = null) + : base(id, runtime, context, agent, logger) + { + if (handoffs.ContainsKey(agent.Name ?? agent.Id)) + { + throw new ArgumentException($"The agent {agent.Name ?? agent.Id} cannot have a handoff to itself.", nameof(handoffs)); + } + + this._cache = []; + this._handoffs = handoffs; + this._resultHandoff = resultHandoff; + } + + /// + /// Gets or sets the callback to be invoked for interactive input. + /// + public OrchestrationInteractiveCallback? InteractiveCallback { get; init; } + + /// + protected override bool ResponseCallbackFilter(ChatMessageContent response) => response.Role == AuthorRole.Tool; + + /// + protected override AgentInvokeOptions? CreateInvokeOptions() + { + // Clone kernel to avoid modifying the original + Kernel kernel = this.Agent.Kernel.Clone(); + kernel.AutoFunctionInvocationFilters.Add(new HandoffInvocationFilter()); + kernel.Plugins.Add(this.CreateHandoffPlugin()); + + // Create invocation options that use auto-function invocation and our modified kernel. + AgentInvokeOptions options = + new() + { + Kernel = kernel, + KernelArguments = new(new PromptExecutionSettings { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() }) + }; + + return options; + } + + /// + public ValueTask HandleAsync(HandoffMessages.InputTask item, MessageContext messageContext) + { + this._taskSummary = null; + this._cache.AddRange(item.Messages); + +#if !NETCOREAPP + return Task.CompletedTask.AsValueTask(); +#else + return ValueTask.CompletedTask; +#endif + } + + /// + public ValueTask HandleAsync(HandoffMessages.Response item, MessageContext messageContext) + { + this._cache.Add(item.Message); + +#if !NETCOREAPP + return Task.CompletedTask.AsValueTask(); +#else + return ValueTask.CompletedTask; +#endif + } + + /// + public async ValueTask HandleAsync(HandoffMessages.Request item, MessageContext messageContext) + { + this.Logger.LogHandoffAgentInvoke(this.Id); + + while (this._taskSummary == null) + { + ChatMessageContent response = await this.InvokeAsync(this._cache, messageContext.CancellationToken).ConfigureAwait(false); + this._cache.Clear(); + + this.Logger.LogHandoffAgentResult(this.Id, response.Content); + + // The response can potentially be a TOOL message from the Handoff plugin due to the filter + // which will terminate the conversation when a function from the handoff plugin is called. + // Since we don't want to publish that message, so we only publish if the response is an ASSISTANT message. + if (response.Role == AuthorRole.Assistant) + { + await this.PublishMessageAsync(new HandoffMessages.Response { Message = response }, this.Context.Topic, messageId: null, messageContext.CancellationToken).ConfigureAwait(false); + } + + if (this._handoffAgent != null) + { + AgentType handoffType = this._handoffs[this._handoffAgent].AgentType; + await this.SendMessageAsync(new HandoffMessages.Request(), handoffType, messageContext.CancellationToken).ConfigureAwait(false); + + this._handoffAgent = null; + break; + } + + if (this.InteractiveCallback != null && this._taskSummary == null) + { + ChatMessageContent input = await this.InteractiveCallback().ConfigureAwait(false); + this._cache.Add(input); + continue; + } + + await this.EndAsync(response.Content ?? "No handoff or human response function requested. Ending task.", messageContext.CancellationToken).ConfigureAwait(false); + } + } + + private KernelPlugin CreateHandoffPlugin() + { + return KernelPluginFactory.CreateFromFunctions(HandoffInvocationFilter.HandoffPlugin, CreateHandoffFunctions()); + + IEnumerable CreateHandoffFunctions() + { + yield return KernelFunctionFactory.CreateFromMethod( + this.EndAsync, + functionName: "end_task_with_summary", + description: "End the task with a summary when there is no further action to take."); + + foreach (KeyValuePair handoff in this._handoffs) + { + KernelFunction kernelFunction = + KernelFunctionFactory.CreateFromMethod( + (CancellationToken cancellationToken) => this.HandoffAsync(handoff.Key, cancellationToken), + functionName: $"transfer_to_{handoff.Key}", + description: handoff.Value.Description); + + yield return kernelFunction; + } + } + } + + private ValueTask HandoffAsync(string agentName, CancellationToken cancellationToken = default) + { + this.Logger.LogHandoffFunctionCall(this.Id, agentName); + this._handoffAgent = agentName; + +#if !NETCOREAPP + return Task.CompletedTask.AsValueTask(); +#else + return ValueTask.CompletedTask; +#endif + } + + private async ValueTask EndAsync(string summary, CancellationToken cancellationToken) + { + this.Logger.LogHandoffSummary(this.Id, summary); + this._taskSummary = summary; + await this.SendMessageAsync(new HandoffMessages.Result { Message = new ChatMessageContent(AuthorRole.Assistant, summary) }, this._resultHandoff, cancellationToken).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Agents/Orchestration/Handoff/HandoffInvocationFilter.cs b/dotnet/src/Agents/Orchestration/Handoff/HandoffInvocationFilter.cs new file mode 100644 index 000000000000..7c67f637bda2 --- /dev/null +++ b/dotnet/src/Agents/Orchestration/Handoff/HandoffInvocationFilter.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; + +namespace Microsoft.SemanticKernel.Agents.Orchestration.Handoff; + +internal sealed class HandoffInvocationFilter() : IAutoFunctionInvocationFilter +{ + public const string HandoffPlugin = nameof(HandoffPlugin); + + public async Task OnAutoFunctionInvocationAsync(AutoFunctionInvocationContext context, Func next) + { + // Execution the function + await next(context).ConfigureAwait(false); + + // Signal termination if the function is part of the handoff plugin + if (context.Function.PluginName == HandoffPlugin) + { + context.Terminate = true; + } + } +} diff --git a/dotnet/src/Agents/Orchestration/Handoff/HandoffMessages.cs b/dotnet/src/Agents/Orchestration/Handoff/HandoffMessages.cs new file mode 100644 index 000000000000..805bfcd08dac --- /dev/null +++ b/dotnet/src/Agents/Orchestration/Handoff/HandoffMessages.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; + +namespace Microsoft.SemanticKernel.Agents.Orchestration.Handoff; + +/// +/// A message that describes the input task and captures results for a . +/// +internal static class HandoffMessages +{ + /// + /// An empty message instance as a default. + /// + internal static readonly ChatMessageContent Empty = new(); + + /// + /// The input message. + /// + public sealed class InputTask + { + /// + /// The orchestration input messages. + /// + public IList Messages { get; init; } = []; + } + + /// + /// The final result. + /// + public sealed class Result + { + /// + /// The orchestration result message. + /// + public ChatMessageContent Message { get; init; } = Empty; + } + + /// + /// Signals the handoff to another agent. + /// + public sealed class Request; + + /// + /// Broadcast an agent response to all actors in the orchestration. + /// + public sealed class Response + { + /// + /// The chat response message. + /// + public ChatMessageContent Message { get; init; } = Empty; + } + + /// + /// Extension method to convert a to a . + /// + public static InputTask AsInputTaskMessage(this IEnumerable messages) => new() { Messages = [.. messages] }; + + /// + /// Extension method to convert a to a . + /// + public static Result AsResultMessage(this ChatMessageContent message) => new() { Message = message }; +} diff --git a/dotnet/src/Agents/Orchestration/Handoff/HandoffOrchestration.String.cs b/dotnet/src/Agents/Orchestration/Handoff/HandoffOrchestration.String.cs new file mode 100644 index 000000000000..21f5fb3c5eca --- /dev/null +++ b/dotnet/src/Agents/Orchestration/Handoff/HandoffOrchestration.String.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.SemanticKernel.Agents.Orchestration.Handoff; + +/// +/// An orchestration that passes the input message to the first agent, and +/// then the subsequent result to the next agent, etc... +/// +public sealed class HandoffOrchestration : HandoffOrchestration +{ + /// + /// Initializes a new instance of the class. + /// + /// Defines the handoff connections for each agent. + /// The agents to be orchestrated. + public HandoffOrchestration(OrchestrationHandoffs handoffs, params Agent[] members) + : base(handoffs, members) + { + } +} diff --git a/dotnet/src/Agents/Orchestration/Handoff/HandoffOrchestration.cs b/dotnet/src/Agents/Orchestration/Handoff/HandoffOrchestration.cs new file mode 100644 index 000000000000..04d75cef719a --- /dev/null +++ b/dotnet/src/Agents/Orchestration/Handoff/HandoffOrchestration.cs @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Orchestration.Extensions; +using Microsoft.SemanticKernel.Agents.Runtime; + +namespace Microsoft.SemanticKernel.Agents.Orchestration.Handoff; + +/// +/// An orchestration that provides the input message to the first agent +/// and Handoffly passes each agent result to the next agent. +/// +public class HandoffOrchestration : AgentOrchestration +{ + private readonly OrchestrationHandoffs _handoffs; + + /// + /// Initializes a new instance of the class. + /// + /// Defines the handoff connections for each agent. + /// The agents participating in the orchestration. + public HandoffOrchestration(OrchestrationHandoffs handoffs, params Agent[] agents) + : base(agents) + { + // Create list of distinct agent names + HashSet agentNames = new(agents.Select(a => a.Name ?? a.Id), StringComparer.Ordinal); + agentNames.Add(handoffs.FirstAgentName); + // Extract names from handoffs that don't align with a member agent. + string[] badNames = [.. handoffs.Keys.Concat(handoffs.Values.SelectMany(h => h.Keys)).Where(name => !agentNames.Contains(name))]; + // Fail fast if invalid names are present. + if (badNames.Length > 0) + { + throw new ArgumentException($"The following agents are not defined in the orchestration: {string.Join(", ", badNames)}", nameof(handoffs)); + } + + this._handoffs = handoffs; + } + + /// + /// Gets or sets the callback to be invoked for interactive input. + /// + public OrchestrationInteractiveCallback? InteractiveCallback { get; init; } + + /// + protected override async ValueTask StartAsync(IAgentRuntime runtime, TopicId topic, IEnumerable input, AgentType? entryAgent) + { + if (!entryAgent.HasValue) + { + throw new ArgumentException("Entry agent is not defined.", nameof(entryAgent)); + } + await runtime.PublishMessageAsync(input.AsInputTaskMessage(), topic).ConfigureAwait(false); + await runtime.SendMessageAsync(new HandoffMessages.Request(), entryAgent.Value).ConfigureAwait(false); + } + + /// + protected override async ValueTask RegisterOrchestrationAsync(IAgentRuntime runtime, OrchestrationContext context, RegistrationContext registrar, ILogger logger) + { + AgentType outputType = await registrar.RegisterResultTypeAsync(response => [response.Message]).ConfigureAwait(false); + + // Each agent handsoff its result to the next agent. + Dictionary agentMap = []; + Dictionary handoffMap = []; + AgentType agentType = outputType; + for (int index = this.Members.Count - 1; index >= 0; --index) + { + Agent agent = this.Members[index]; + HandoffLookup map = []; + handoffMap[agent.Name ?? agent.Id] = map; + agentType = + await runtime.RegisterAgentFactoryAsync( + this.GetAgentType(context.Topic, index), + (agentId, runtime) => + { + HandoffActor actor = + new(agentId, runtime, context, agent, map, outputType, context.LoggerFactory.CreateLogger()) + { + InteractiveCallback = this.InteractiveCallback + }; +#if !NETCOREAPP + return actor.AsValueTask(); +#else + return ValueTask.FromResult(actor); +#endif + }).ConfigureAwait(false); + agentMap[agent.Name ?? agent.Id] = agentType; + + await runtime.SubscribeAsync(agentType, context.Topic).ConfigureAwait(false); + + logger.LogRegisterActor(this.OrchestrationLabel, agentType, "MEMBER", index + 1); + } + + // Complete the handoff model + foreach (KeyValuePair handoffs in this._handoffs) + { + // Retrieve the map for the agent (every agent had an empty map created) + HandoffLookup agentHandoffs = handoffMap[handoffs.Key]; + foreach (KeyValuePair handoff in handoffs.Value) + { + // name = (type,description) + agentHandoffs[handoff.Key] = (agentMap[handoff.Key], handoff.Value); + } + } + + return agentMap[this._handoffs.FirstAgentName]; + } + + private AgentType GetAgentType(TopicId topic, int index) => this.FormatAgentType(topic, $"Agent_{index + 1}"); +} diff --git a/dotnet/src/Agents/Orchestration/Handoff/Handoffs.cs b/dotnet/src/Agents/Orchestration/Handoff/Handoffs.cs new file mode 100644 index 000000000000..3e145e606496 --- /dev/null +++ b/dotnet/src/Agents/Orchestration/Handoff/Handoffs.cs @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.SemanticKernel.Agents.Runtime; + +namespace Microsoft.SemanticKernel.Agents.Orchestration.Handoff; + +/// +/// Defines the handoff relationships for a given agent. +/// Maps target agent names/IDs to handoff descriptions. +/// +public sealed class AgentHandoffs : Dictionary +{ + /// + /// Initializes a new instance of the class with no handoff relationships. + /// + public AgentHandoffs() { } + + /// + /// Initializes a new instance of the class with the specified handoff relationships. + /// + /// A dictionary mapping target agent names/IDs to handoff descriptions. + public AgentHandoffs(Dictionary handoffs) : base(handoffs) { } +} + +/// +/// Defines the orchestration handoff relationships for all agents in the system. +/// Maps source agent names/IDs to their . +/// +public sealed class OrchestrationHandoffs : Dictionary +{ + /// + /// Initializes a new instance of the class with no handoff relationships. + /// + /// The first agent to be invoked (prior to any handoff). + public OrchestrationHandoffs(Agent firstAgent) + : this(firstAgent.Name ?? firstAgent.Id) + { } + + /// + /// Initializes a new instance of the class with no handoff relationships. + /// + /// The name of the first agent to be invoked (prior to any handoff). + public OrchestrationHandoffs(string firstAgentName) + { + Verify.NotNullOrWhiteSpace(firstAgentName, nameof(firstAgentName)); + this.FirstAgentName = firstAgentName; + } + + /// + /// The name of the first agent to be invoked (prior to any handoff). + /// + public string FirstAgentName { get; } + + /// + /// Adds handoff relationships from a source agent to one or more target agents. + /// Each target agent's name or ID is mapped to its description. + /// + /// The source agent. + /// The updated instance. + public static OrchestrationHandoffs StartWith(Agent source) => new(source); +} + +/// +/// Extension methods for building and modifying relationships. +/// +public static class OrchestrationHandoffsExtensions +{ + /// + /// Adds handoff relationships from a source agent to one or more target agents. + /// Each target agent's name or ID is mapped to its description. + /// + /// The orchestration handoffs collection to update. + /// The source agent. + /// The target agents to add as handoff targets for the source agent. + /// The updated instance. + public static OrchestrationHandoffs Add(this OrchestrationHandoffs handoffs, Agent source, params Agent[] targets) + { + string key = source.Name ?? source.Id; + + AgentHandoffs agentHandoffs = handoffs.GetAgentHandoffs(key); + + foreach (Agent target in targets) + { + agentHandoffs[target.Name ?? target.Id] = target.Description ?? string.Empty; + } + + return handoffs; + } + + /// + /// Adds a handoff relationship from a source agent to a target agent with a custom description. + /// + /// The orchestration handoffs collection to update. + /// The source agent. + /// The target agent. + /// The handoff description. + /// The updated instance. + public static OrchestrationHandoffs Add(this OrchestrationHandoffs handoffs, Agent source, Agent target, string description) + => handoffs.Add(source.Name ?? source.Id, target.Name ?? target.Id, description); + + /// + /// Adds a handoff relationship from a source agent to a target agent name/ID with a custom description. + /// + /// The orchestration handoffs collection to update. + /// The source agent. + /// The target agent's name or ID. + /// The handoff description. + /// The updated instance. + public static OrchestrationHandoffs Add(this OrchestrationHandoffs handoffs, Agent source, string targetName, string description) + => handoffs.Add(source.Name ?? source.Id, targetName, description); + + /// + /// Adds a handoff relationship from a source agent name/ID to a target agent name/ID with a custom description. + /// + /// The orchestration handoffs collection to update. + /// The source agent's name or ID. + /// The target agent's name or ID. + /// The handoff description. + /// The updated instance. + public static OrchestrationHandoffs Add(this OrchestrationHandoffs handoffs, string sourceName, string targetName, string description) + { + AgentHandoffs agentHandoffs = handoffs.GetAgentHandoffs(sourceName); + agentHandoffs[targetName] = description; + + return handoffs; + } + + private static AgentHandoffs GetAgentHandoffs(this OrchestrationHandoffs handoffs, string key) + { + if (!handoffs.TryGetValue(key, out AgentHandoffs? agentHandoffs)) + { + agentHandoffs = []; + handoffs[key] = agentHandoffs; + } + + return agentHandoffs; + } +} + +/// +/// Handoff relationships post-processed into a name-based lookup table that includes the agent type and handoff description. +/// Maps agent names/IDs to a tuple of and handoff description. +/// +internal sealed class HandoffLookup : Dictionary; diff --git a/dotnet/src/Agents/Orchestration/Logging/AgentOrchestrationLogMessages.cs b/dotnet/src/Agents/Orchestration/Logging/AgentOrchestrationLogMessages.cs new file mode 100644 index 000000000000..043d24934b12 --- /dev/null +++ b/dotnet/src/Agents/Orchestration/Logging/AgentOrchestrationLogMessages.cs @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Runtime; + +namespace Microsoft.SemanticKernel.Agents.Orchestration; + +/// +/// Extensions for logging . +/// +/// +/// This extension uses the to +/// generate logging code at compile time to achieve optimized code. +/// +[ExcludeFromCodeCoverage] +internal static partial class AgentOrchestrationLogMessages +{ + /// + /// Logs the start of the registration phase for an orchestration. + /// + [LoggerMessage( + EventId = 0, + Level = LogLevel.Trace, + Message = "REGISTER {Orchestration} Start: {Topic}")] + public static partial void LogOrchestrationRegistrationStart( + this ILogger logger, + string orchestration, + TopicId topic); + + /// + /// Logs pattern actor registration. + /// + [LoggerMessage( + EventId = 0, + Level = LogLevel.Information, + Message = "REGISTER ACTOR {Orchestration} {label}: {AgentType}")] + public static partial void LogRegisterActor( + this ILogger logger, + string orchestration, + AgentType agentType, + string label); + + /// + /// Logs agent actor registration. + /// + [LoggerMessage( + EventId = 0, + Level = LogLevel.Information, + Message = "REGISTER ACTOR {Orchestration} {label} #{Count}: {AgentType}")] + public static partial void LogRegisterActor( + this ILogger logger, + string orchestration, + AgentType agentType, + string label, + int count); + + /// + /// Logs the end of the registration phase for an orchestration. + /// + [LoggerMessage( + EventId = 0, + Level = LogLevel.Trace, + Message = "REGISTER {Orchestration} Complete: {Topic}")] + public static partial void LogOrchestrationRegistrationDone( + this ILogger logger, + string orchestration, + TopicId topic); + + /// + /// Logs an orchestration invocation + /// + [LoggerMessage( + EventId = 0, + Level = LogLevel.Information, + Message = "INVOKE {Orchestration}: {Topic}")] + public static partial void LogOrchestrationInvoke( + this ILogger logger, + string orchestration, + TopicId topic); + + /// + /// Logs that the orchestration has started successfully and + /// yielded control back to the caller. + /// + [LoggerMessage( + EventId = 0, + Level = LogLevel.Trace, + Message = "YIELD {Orchestration}: {Topic}")] + public static partial void LogOrchestrationYield( + this ILogger logger, + string orchestration, + TopicId topic); + + /// + /// Logs the start an orchestration (top/outer). + /// + [LoggerMessage( + EventId = 0, + Level = LogLevel.Information, + Message = "START {Orchestration}: {AgentId}")] + public static partial void LogOrchestrationStart( + this ILogger logger, + string orchestration, + AgentId agentId); + + /// + /// Logs that orchestration request actor is active + /// + [LoggerMessage( + EventId = 0, + Level = LogLevel.Information, + Message = "INIT {Orchestration}: {AgentId}")] + public static partial void LogOrchestrationRequestInvoke( + this ILogger logger, + string orchestration, + AgentId agentId); + + /// + /// Logs that orchestration request actor experienced an unexpected failure. + /// + [LoggerMessage( + EventId = 0, + Level = LogLevel.Error, + Message = "FAILURE {Orchestration}: {AgentId}")] + public static partial void LogOrchestrationRequestFailure( + this ILogger logger, + string orchestration, + AgentId agentId, + Exception exception); + + /// + /// Logs that orchestration result actor is active + /// + [LoggerMessage( + EventId = 0, + Level = LogLevel.Information, + Message = "EXIT {Orchestration}: {AgentId}")] + public static partial void LogOrchestrationResultInvoke( + this ILogger logger, + string orchestration, + AgentId agentId); + + /// + /// Logs that orchestration result actor experienced an unexpected failure. + /// + [LoggerMessage( + EventId = 0, + Level = LogLevel.Error, + Message = "FAILURE {Orchestration}: {AgentId}")] + public static partial void LogOrchestrationResultFailure( + this ILogger logger, + string orchestration, + AgentId agentId, + Exception exception); +} diff --git a/dotnet/src/Agents/Orchestration/Logging/ConcurrentOrchestrationLogMessages.cs b/dotnet/src/Agents/Orchestration/Logging/ConcurrentOrchestrationLogMessages.cs new file mode 100644 index 000000000000..0f24fa1c2939 --- /dev/null +++ b/dotnet/src/Agents/Orchestration/Logging/ConcurrentOrchestrationLogMessages.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Orchestration.Concurrent; +using Microsoft.SemanticKernel.Agents.Runtime; + +namespace Microsoft.SemanticKernel.Agents.Orchestration; + +/// +/// Extensions for logging . +/// +/// +/// This extension uses the to +/// generate logging code at compile time to achieve optimized code. +/// +[ExcludeFromCodeCoverage] +internal static partial class ConcurrentOrchestrationLogMessages +{ + [LoggerMessage( + EventId = 0, + Level = LogLevel.Trace, + Message = "REQUEST Concurrent agent [{AgentId}]")] + public static partial void LogConcurrentAgentInvoke( + this ILogger logger, + AgentId agentId); + + [LoggerMessage( + EventId = 0, + Level = LogLevel.Trace, + Message = "RESULT Concurrent agent [{AgentId}]: {Message}")] + public static partial void LogConcurrentAgentResult( + this ILogger logger, + AgentId agentId, + string? message); + + /// + /// Logs result capture. + /// + [LoggerMessage( + EventId = 0, + Level = LogLevel.Information, + Message = "COLLECT Concurrent result [{AgentId}]: #{ResultCount} / {ExpectedCount}")] + public static partial void LogConcurrentResultCapture( + this ILogger logger, + AgentId agentId, + int resultCount, + int expectedCount); +} diff --git a/dotnet/src/Agents/Orchestration/Logging/GroupChatOrchestrationLogMessages.cs b/dotnet/src/Agents/Orchestration/Logging/GroupChatOrchestrationLogMessages.cs new file mode 100644 index 000000000000..82c86ddc5cff --- /dev/null +++ b/dotnet/src/Agents/Orchestration/Logging/GroupChatOrchestrationLogMessages.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Orchestration.GroupChat; +using Microsoft.SemanticKernel.Agents.Runtime; + +namespace Microsoft.SemanticKernel.Agents.Orchestration; + +/// +/// Extensions for logging . +/// +/// +/// This extension uses the to +/// generate logging code at compile time to achieve optimized code. +/// +[ExcludeFromCodeCoverage] +internal static partial class GroupChatOrchestrationLogMessages +{ + [LoggerMessage( + EventId = 0, + Level = LogLevel.Trace, + Message = "CHAT AGENT invoked [{AgentId}]")] + public static partial void LogChatAgentInvoke( + this ILogger logger, + AgentId agentId); + + [LoggerMessage( + EventId = 0, + Level = LogLevel.Trace, + Message = "CHAT AGENT result [{AgentId}]: {Message}")] + public static partial void LogChatAgentResult( + this ILogger logger, + AgentId agentId, + string? message); + + [LoggerMessage( + EventId = 0, + Level = LogLevel.Debug, + Message = "CHAT MANAGER initialized [{AgentId}]")] + public static partial void LogChatManagerInit( + this ILogger logger, + AgentId agentId); + + [LoggerMessage( + EventId = 0, + Level = LogLevel.Debug, + Message = "CHAT MANAGER invoked [{AgentId}]")] + public static partial void LogChatManagerInvoke( + this ILogger logger, + AgentId agentId); + + [LoggerMessage( + EventId = 0, + Level = LogLevel.Debug, + Message = "CHAT MANAGER terminate? [{AgentId}]: {Result} ({Reason})")] + public static partial void LogChatManagerTerminate( + this ILogger logger, + AgentId agentId, + bool result, + string reason); + + [LoggerMessage( + EventId = 0, + Level = LogLevel.Debug, + Message = "CHAT MANAGER select: {NextAgent} [{AgentId}]")] + public static partial void LogChatManagerSelect( + this ILogger logger, + AgentId agentId, + AgentType nextAgent); + + [LoggerMessage( + EventId = 0, + Level = LogLevel.Debug, + Message = "CHAT MANAGER result [{AgentId}]: '{Result}' ({Reason})")] + public static partial void LogChatManagerResult( + this ILogger logger, + AgentId agentId, + string result, + string reason); + + [LoggerMessage( + EventId = 0, + Level = LogLevel.Debug, + Message = "CHAT MANAGER user-input? [{AgentId}]: {Result} ({Reason})")] + public static partial void LogChatManagerInput( + this ILogger logger, + AgentId agentId, + bool result, + string reason); + + [LoggerMessage( + EventId = 0, + Level = LogLevel.Trace, + Message = "CHAT AGENT user-input [{AgentId}]: {Message}")] + public static partial void LogChatManagerUserInput( + this ILogger logger, + AgentId agentId, + string? message); +} diff --git a/dotnet/src/Agents/Orchestration/Logging/HandoffOrchestrationLogMessages.cs b/dotnet/src/Agents/Orchestration/Logging/HandoffOrchestrationLogMessages.cs new file mode 100644 index 000000000000..59df59911f0c --- /dev/null +++ b/dotnet/src/Agents/Orchestration/Logging/HandoffOrchestrationLogMessages.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Orchestration.Handoff; +using Microsoft.SemanticKernel.Agents.Runtime; + +namespace Microsoft.SemanticKernel.Agents.Orchestration; + +/// +/// Extensions for logging . +/// +/// +/// This extension uses the to +/// generate logging code at compile time to achieve optimized code. +/// +[ExcludeFromCodeCoverage] +internal static partial class HandoffOrchestrationLogMessages +{ + [LoggerMessage( + EventId = 0, + Level = LogLevel.Trace, + Message = "REQUEST Handoff agent [{AgentId}]")] + public static partial void LogHandoffAgentInvoke( + this ILogger logger, + AgentId agentId); + + [LoggerMessage( + EventId = 0, + Level = LogLevel.Trace, + Message = "RESULT Handoff agent [{AgentId}]: {Message}")] + public static partial void LogHandoffAgentResult( + this ILogger logger, + AgentId agentId, + string? message); + + [LoggerMessage( + EventId = 0, + Level = LogLevel.Trace, + Message = "TOOL Handoff [{AgentId}]: {Name}")] + public static partial void LogHandoffFunctionCall( + this ILogger logger, + AgentId agentId, + string name); + + [LoggerMessage( + EventId = 0, + Level = LogLevel.Trace, + Message = "RESULT Handoff summary [{AgentId}]: {Summary}")] + public static partial void LogHandoffSummary( + this ILogger logger, + AgentId agentId, + string? summary); +} diff --git a/dotnet/src/Agents/Orchestration/Logging/OrchestrationResultLogMessages.cs b/dotnet/src/Agents/Orchestration/Logging/OrchestrationResultLogMessages.cs new file mode 100644 index 000000000000..fcd902b5aaf1 --- /dev/null +++ b/dotnet/src/Agents/Orchestration/Logging/OrchestrationResultLogMessages.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Runtime; + +namespace Microsoft.SemanticKernel.Agents.Orchestration; + +/// +/// Extensions for logging . +/// +/// +/// This extension uses the to +/// generate logging code at compile time to achieve optimized code. +/// +[ExcludeFromCodeCoverage] +internal static partial class OrchestrationResultLogMessages +{ + /// + /// Logs awaiting the orchestration. + /// + [LoggerMessage( + EventId = 0, + Level = LogLevel.Trace, + Message = "AWAIT {Orchestration}: {Topic}")] + public static partial void LogOrchestrationResultAwait( + this ILogger logger, + string orchestration, + TopicId topic); + + /// + /// Logs timeout while awaiting the orchestration. + /// + [LoggerMessage( + EventId = 0, + Level = LogLevel.Error, + Message = "TIMEOUT {Orchestration}: {Topic}")] + public static partial void LogOrchestrationResultTimeout( + this ILogger logger, + string orchestration, + TopicId topic); + + /// + /// Logs cancelled the orchestration. + /// + [LoggerMessage( + EventId = 0, + Level = LogLevel.Error, + Message = "CANCELLED {Orchestration}: {Topic}")] + public static partial void LogOrchestrationResultCancelled( + this ILogger logger, + string orchestration, + TopicId topic); + + /// + /// Logs the awaited the orchestration has completed. + /// + [LoggerMessage( + EventId = 0, + Level = LogLevel.Trace, + Message = "COMPLETE {Orchestration}: {Topic}")] + public static partial void LogOrchestrationResultComplete( + this ILogger logger, + string orchestration, + TopicId topic); +} diff --git a/dotnet/src/Agents/Orchestration/Logging/SequentialOrchestrationLogMessages.cs b/dotnet/src/Agents/Orchestration/Logging/SequentialOrchestrationLogMessages.cs new file mode 100644 index 000000000000..c82d6dab4186 --- /dev/null +++ b/dotnet/src/Agents/Orchestration/Logging/SequentialOrchestrationLogMessages.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Orchestration.Sequential; +using Microsoft.SemanticKernel.Agents.Runtime; + +namespace Microsoft.SemanticKernel.Agents.Orchestration; + +/// +/// Extensions for logging . +/// +/// +/// This extension uses the to +/// generate logging code at compile time to achieve optimized code. +/// +[ExcludeFromCodeCoverage] +internal static partial class SequentialOrchestrationLogMessages +{ + [LoggerMessage( + EventId = 0, + Level = LogLevel.Trace, + Message = "REQUEST Sequential agent [{AgentId}]")] + public static partial void LogSequentialAgentInvoke( + this ILogger logger, + AgentId agentId); + + [LoggerMessage( + EventId = 0, + Level = LogLevel.Trace, + Message = "RESULT Sequential agent [{AgentId}]: {Message}")] + public static partial void LogSequentialAgentResult( + this ILogger logger, + AgentId agentId, + string? message); +} diff --git a/dotnet/src/Agents/Orchestration/OrchestrationActor.cs b/dotnet/src/Agents/Orchestration/OrchestrationActor.cs new file mode 100644 index 000000000000..1e7866d2ca86 --- /dev/null +++ b/dotnet/src/Agents/Orchestration/OrchestrationActor.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Runtime; +using Microsoft.SemanticKernel.Agents.Runtime.Core; + +namespace Microsoft.SemanticKernel.Agents.Orchestration; + +/// +/// Base abstractions for any actor that participates in an orchestration. +/// +public abstract class OrchestrationActor : BaseAgent +{ + /// + /// Initializes a new instance of the class. + /// + protected OrchestrationActor(AgentId id, IAgentRuntime runtime, OrchestrationContext context, string description, ILogger? logger = null) + : base(id, runtime, description, logger) + { + this.Context = context; + } + + /// + /// The orchestration context. + /// + protected OrchestrationContext Context { get; } + + /// + /// Sends a message to a specified recipient agent-type through the runtime. + /// + /// The message object to send. + /// The recipient agent's type. + /// A token used to cancel the operation if needed. + /// The agent identifier, if it exists. + protected async ValueTask SendMessageAsync( + object message, + AgentType agentType, + CancellationToken cancellationToken = default) + { + AgentId? agentId = await this.GetAgentAsync(agentType, cancellationToken).ConfigureAwait(false); + + if (agentId.HasValue) + { + await this.SendMessageAsync(message, agentId.Value, messageId: null, cancellationToken).ConfigureAwait(false); + } + + return agentId; + } +} diff --git a/dotnet/src/Agents/Orchestration/OrchestrationContext.cs b/dotnet/src/Agents/Orchestration/OrchestrationContext.cs new file mode 100644 index 000000000000..354ae12840fd --- /dev/null +++ b/dotnet/src/Agents/Orchestration/OrchestrationContext.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Runtime; + +namespace Microsoft.SemanticKernel.Agents.Orchestration; + +/// +/// Provides contextual information for an orchestration operation, including topic, cancellation, logging, and response callback. +/// +public sealed class OrchestrationContext +{ + internal OrchestrationContext( + string orchestration, + TopicId topic, + OrchestrationResponseCallback? responseCallback, + ILoggerFactory loggerFactory, + CancellationToken cancellation) + { + this.Orchestration = orchestration; + this.Topic = topic; + this.ResponseCallback = responseCallback; + this.LoggerFactory = loggerFactory; + this.Cancellation = cancellation; + } + + /// + /// Gets the name or identifier of the orchestration. + /// + public string Orchestration { get; } + + /// + /// Gets the identifier associated with orchestration topic. + /// + /// + /// All orchestration actors are subscribed to this topic. + /// + public TopicId Topic { get; } + + /// + /// Gets the cancellation token that can be used to observe cancellation requests for the orchestration. + /// + public CancellationToken Cancellation { get; } + + /// + /// Gets the associated logger factory for creating loggers within the orchestration context. + /// + public ILoggerFactory LoggerFactory { get; } + + /// + /// Optional callback that is invoked for every agent response. + /// + public OrchestrationResponseCallback? ResponseCallback { get; } +} diff --git a/dotnet/src/Agents/Orchestration/OrchestrationResult.cs b/dotnet/src/Agents/Orchestration/OrchestrationResult.cs new file mode 100644 index 000000000000..978e7bcca74b --- /dev/null +++ b/dotnet/src/Agents/Orchestration/OrchestrationResult.cs @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Runtime; + +namespace Microsoft.SemanticKernel.Agents.Orchestration; + +/// +/// Represents the result of an orchestration operation that yields a value of type . +/// This class encapsulates the asynchronous completion of an orchestration process. +/// +/// The type of the value produced by the orchestration. +public sealed class OrchestrationResult : IDisposable +{ + private readonly OrchestrationContext _context; + private readonly CancellationTokenSource _cancelSource; + private readonly TaskCompletionSource _completion; + private readonly ILogger _logger; + private bool _isDisposed; + + internal OrchestrationResult(OrchestrationContext context, TaskCompletionSource completion, CancellationTokenSource orchestrationCancelSource, ILogger logger) + { + this._cancelSource = orchestrationCancelSource; + this._context = context; + this._completion = completion; + this._logger = logger; + } + + /// + /// Releases all resources used by the instance. + /// + public void Dispose() + { + this.Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// + /// Gets the orchestration name associated with this orchestration result. + /// + public string Orchestration => this._context.Orchestration; + + /// + /// Gets the topic identifier associated with this orchestration result. + /// + public TopicId Topic => this._context.Topic; + + /// + /// Asynchronously retrieves the orchestration result value. + /// If a timeout is specified, the method will throw a + /// if the orchestration does not complete within the allotted time. + /// + /// An optional representing the maximum wait duration. + /// A cancellation token that can be used to cancel the operation. + /// A representing the result of the orchestration. + /// Thrown if this instance has been disposed. + /// Thrown if the orchestration does not complete within the specified timeout period. + public async ValueTask GetValueAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { +#if !NETCOREAPP + if (this._isDisposed) + { + throw new ObjectDisposedException(this.GetType().Name); + } +#else + ObjectDisposedException.ThrowIf(this._isDisposed, this); +#endif + + this._logger.LogOrchestrationResultAwait(this.Orchestration, this.Topic); + + if (timeout.HasValue) + { + Task[] tasks = { this._completion.Task }; + if (!Task.WaitAll(tasks, timeout.Value)) + { + this._logger.LogOrchestrationResultTimeout(this.Orchestration, this.Topic); + throw new TimeoutException($"Orchestration did not complete within the allowed duration ({timeout})."); + } + } + + this._logger.LogOrchestrationResultComplete(this.Orchestration, this.Topic); + + return await this._completion.Task.ConfigureAwait(false); + } + + /// + /// Cancel the orchestration associated with this result. + /// + /// Thrown if this instance has been disposed. + /// + /// Cancellation is not expected to immediately halt the orchestration. Messages that + /// are already in-flight may still be processed. + /// + public void Cancel() + { +#if !NETCOREAPP + if (this._isDisposed) + { + throw new ObjectDisposedException(this.GetType().Name); + } +#else + ObjectDisposedException.ThrowIf(this._isDisposed, this); +#endif + + this._logger.LogOrchestrationResultCancelled(this.Orchestration, this.Topic); + this._cancelSource.Cancel(); + this._completion.SetCanceled(); + } + + private void Dispose(bool disposing) + { + if (!this._isDisposed) + { + if (disposing) + { + this._cancelSource.Dispose(); + } + + this._isDisposed = true; + } + } +} diff --git a/dotnet/src/Agents/Orchestration/Properties/AssemblyInfo.cs b/dotnet/src/Agents/Orchestration/Properties/AssemblyInfo.cs new file mode 100644 index 000000000000..bd1c0f58314e --- /dev/null +++ b/dotnet/src/Agents/Orchestration/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; + +// This assembly is currently experimental. +[assembly: Experimental("SKEXP0110")] diff --git a/dotnet/src/Agents/Orchestration/Sequential/SequentialActor.cs b/dotnet/src/Agents/Orchestration/Sequential/SequentialActor.cs new file mode 100644 index 000000000000..8af67d287176 --- /dev/null +++ b/dotnet/src/Agents/Orchestration/Sequential/SequentialActor.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Runtime; +using Microsoft.SemanticKernel.Agents.Runtime.Core; + +namespace Microsoft.SemanticKernel.Agents.Orchestration.Sequential; + +/// +/// An actor used with the . +/// +internal sealed class SequentialActor : + AgentActor, + IHandle, + IHandle +{ + private readonly AgentType _nextAgent; + + /// + /// Initializes a new instance of the class. + /// + /// The unique identifier of the agent. + /// The runtime associated with the agent. + /// The orchestration context. + /// An . + /// The identifier of the next agent for which to handoff the result + /// The logger to use for the actor + public SequentialActor(AgentId id, IAgentRuntime runtime, OrchestrationContext context, Agent agent, AgentType nextAgent, ILogger? logger = null) + : base(id, runtime, context, agent, logger) + { + logger?.LogInformation("ACTOR {ActorId} {NextAgent}", this.Id, nextAgent); + this._nextAgent = nextAgent; + } + + /// + public async ValueTask HandleAsync(SequentialMessages.Request item, MessageContext messageContext) + { + await this.InvokeAgentAsync(item.Messages, messageContext).ConfigureAwait(false); + } + + /// + public async ValueTask HandleAsync(SequentialMessages.Response item, MessageContext messageContext) + { + await this.InvokeAgentAsync([item.Message], messageContext).ConfigureAwait(false); + } + + private async ValueTask InvokeAgentAsync(IList input, MessageContext messageContext) + { + this.Logger.LogInformation("INVOKE {ActorId} {NextAgent}", this.Id, this._nextAgent); + + this.Logger.LogSequentialAgentInvoke(this.Id); + + ChatMessageContent response = await this.InvokeAsync(input, messageContext.CancellationToken).ConfigureAwait(false); + + this.Logger.LogSequentialAgentResult(this.Id, response.Content); + + await this.SendMessageAsync(response.AsResponseMessage(), this._nextAgent, messageContext.CancellationToken).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Agents/Orchestration/Sequential/SequentialMessages.cs b/dotnet/src/Agents/Orchestration/Sequential/SequentialMessages.cs new file mode 100644 index 000000000000..c06f30f8a046 --- /dev/null +++ b/dotnet/src/Agents/Orchestration/Sequential/SequentialMessages.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; + +namespace Microsoft.SemanticKernel.Agents.Orchestration.Sequential; + +/// +/// A message that describes the input task and captures results for a . +/// +internal static class SequentialMessages +{ + /// + /// An empty message instance as a default. + /// + public static readonly ChatMessageContent Empty = new(); + + /// + /// Represents a request containing a sequence of chat messages to be processed by the sequential orchestration. + /// + public sealed class Request + { + /// + /// The request input. + /// + public IList Messages { get; init; } = []; + } + + /// + /// Represents a response containing the result message from the sequential orchestration. + /// + public sealed class Response + { + /// + /// The response message. + /// + public ChatMessageContent Message { get; init; } = Empty; + } + + /// + /// Extension method to convert a to a . + /// + /// The chat message to include in the request. + /// A containing the provided messages. + public static Request AsRequestMessage(this ChatMessageContent message) => new() { Messages = [message] }; + + /// + /// Extension method to convert a collection of to a . + /// + /// The collection of chat messages to include in the request. + /// A containing the provided messages. + public static Request AsRequestMessage(this IEnumerable messages) => new() { Messages = [.. messages] }; + + /// + /// Extension method to convert a to a . + /// + /// The chat message to include in the response. + /// A containing the provided message. + public static Response AsResponseMessage(this ChatMessageContent message) => new() { Message = message }; +} diff --git a/dotnet/src/Agents/Orchestration/Sequential/SequentialOrchestration.String.cs b/dotnet/src/Agents/Orchestration/Sequential/SequentialOrchestration.String.cs new file mode 100644 index 000000000000..29bb1ee362ca --- /dev/null +++ b/dotnet/src/Agents/Orchestration/Sequential/SequentialOrchestration.String.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.SemanticKernel.Agents.Orchestration.Sequential; + +/// +/// An orchestration that passes the input message to the first agent, and +/// then the subsequent result to the next agent, etc... +/// +public sealed class SequentialOrchestration : SequentialOrchestration +{ + /// + /// Initializes a new instance of the class. + /// + /// The agents to be orchestrated. + public SequentialOrchestration(params Agent[] members) + : base(members) + { + } +} diff --git a/dotnet/src/Agents/Orchestration/Sequential/SequentialOrchestration.cs b/dotnet/src/Agents/Orchestration/Sequential/SequentialOrchestration.cs new file mode 100644 index 000000000000..be13c1f87fe3 --- /dev/null +++ b/dotnet/src/Agents/Orchestration/Sequential/SequentialOrchestration.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Orchestration.Extensions; +using Microsoft.SemanticKernel.Agents.Runtime; + +namespace Microsoft.SemanticKernel.Agents.Orchestration.Sequential; + +/// +/// An orchestration that provides the input message to the first agent +/// and sequentially passes each agent result to the next agent. +/// +public class SequentialOrchestration : AgentOrchestration +{ + /// + /// Initializes a new instance of the class. + /// + /// The agents participating in the orchestration. + public SequentialOrchestration(params Agent[] agents) + : base(agents) + { + } + + /// + protected override async ValueTask StartAsync(IAgentRuntime runtime, TopicId topic, IEnumerable input, AgentType? entryAgent) + { + if (!entryAgent.HasValue) + { + throw new ArgumentException("Entry agent is not defined.", nameof(entryAgent)); + } + await runtime.SendMessageAsync(input.AsRequestMessage(), entryAgent.Value).ConfigureAwait(false); + } + + /// + protected override async ValueTask RegisterOrchestrationAsync(IAgentRuntime runtime, OrchestrationContext context, RegistrationContext registrar, ILogger logger) + { + AgentType outputType = await registrar.RegisterResultTypeAsync(response => [response.Message]).ConfigureAwait(false); + + // Each agent handsoff its result to the next agent. + AgentType nextAgent = outputType; + for (int index = this.Members.Count - 1; index >= 0; --index) + { + Agent agent = this.Members[index]; + nextAgent = await RegisterAgentAsync(agent, index, nextAgent).ConfigureAwait(false); + + logger.LogRegisterActor(this.OrchestrationLabel, nextAgent, "MEMBER", index + 1); + } + + return nextAgent; + + ValueTask RegisterAgentAsync(Agent agent, int index, AgentType nextAgent) => + runtime.RegisterAgentFactoryAsync( + this.GetAgentType(context.Topic, index), + (agentId, runtime) => + { + SequentialActor actor = new(agentId, runtime, context, agent, nextAgent, context.LoggerFactory.CreateLogger()); +#if !NETCOREAPP + return actor.AsValueTask(); +#else + return ValueTask.FromResult(actor); +#endif + }); + } + + private AgentType GetAgentType(TopicId topic, int index) => this.FormatAgentType(topic, $"Agent_{index + 1}"); +} diff --git a/dotnet/src/Agents/Orchestration/Transforms/DefaultTransforms.cs b/dotnet/src/Agents/Orchestration/Transforms/DefaultTransforms.cs new file mode 100644 index 000000000000..51d3c9c3a465 --- /dev/null +++ b/dotnet/src/Agents/Orchestration/Transforms/DefaultTransforms.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace Microsoft.SemanticKernel.Agents.Orchestration.Transforms; + +internal static class DefaultTransforms +{ + public static ValueTask> FromInput(TInput input, CancellationToken cancellationToken = default) + { +#if !NETCOREAPP + return TransformInput().AsValueTask(); +#else + return ValueTask.FromResult(TransformInput()); +#endif + + IEnumerable TransformInput() => + input switch + { + IEnumerable messages => messages, + ChatMessageContent message => [message], + string text => [new ChatMessageContent(AuthorRole.User, text)], + _ => [new ChatMessageContent(AuthorRole.User, JsonSerializer.Serialize(input))] + }; + } + + public static ValueTask ToOutput(IList result, CancellationToken cancellationToken = default) + { + bool isSingleResult = result.Count == 1; + + TOutput output = + GetDefaultOutput() ?? + GetObjectOutput() ?? + throw new InvalidOperationException($"Unable to transform output to {typeof(TOutput)}."); + + return new ValueTask(output); + + TOutput? GetObjectOutput() + { + if (!isSingleResult) + { + return default; + } + + try + { + return JsonSerializer.Deserialize(result[0].Content ?? string.Empty); + } + catch (JsonException) + { + return default; + } + } + + TOutput? GetDefaultOutput() + { + object? output = null; + if (typeof(TOutput).IsAssignableFrom(result.GetType())) + { + output = (object)result; + } + else if (isSingleResult && typeof(ChatMessageContent).IsAssignableFrom(typeof(TOutput))) + { + output = (object)result[0]; + } + else if (isSingleResult && typeof(string) == typeof(TOutput)) + { + output = result[0].Content ?? string.Empty; + } + + return (TOutput?)output; + } + } +} diff --git a/dotnet/src/Agents/Orchestration/Transforms/OrchestrationTransforms.cs b/dotnet/src/Agents/Orchestration/Transforms/OrchestrationTransforms.cs new file mode 100644 index 000000000000..5b691b310d60 --- /dev/null +++ b/dotnet/src/Agents/Orchestration/Transforms/OrchestrationTransforms.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.SemanticKernel.Agents.Orchestration.Transforms; + +/// +/// Delegate for transforming an input of type into a collection of . +/// This is typically used to convert user or system input into a format suitable for chat orchestration. +/// +/// The input object to transform. +/// A cancellation token that can be used to cancel the operation. +/// A containing an enumerable of representing the transformed input. +public delegate ValueTask> OrchestrationInputTransform(TInput input, CancellationToken cancellationToken = default); + +/// +/// Delegate for transforming a into an output of type . +/// This is typically used to convert a chat response into a desired output format. +/// +/// The result messages to transform. +/// A cancellation token that can be used to cancel the operation. +/// A containing the transformed output of type . +public delegate ValueTask OrchestrationOutputTransform(IList result, CancellationToken cancellationToken = default); + +/// +/// Delegate for transforming the internal result message for an orchestration into a . +/// +/// The result message type +/// The result messages +/// The orchestration result as a . +public delegate IList OrchestrationResultTransform(TResult result); diff --git a/dotnet/src/Agents/Orchestration/Transforms/StructuredOutputTransform.cs b/dotnet/src/Agents/Orchestration/Transforms/StructuredOutputTransform.cs new file mode 100644 index 000000000000..d6dc8494a287 --- /dev/null +++ b/dotnet/src/Agents/Orchestration/Transforms/StructuredOutputTransform.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace Microsoft.SemanticKernel.Agents.Orchestration.Transforms; + +/// +/// Populates the target result type into a structured output. +/// +/// The .NET type of the structured-output to deserialization target. +public sealed class StructuredOutputTransform +{ + internal const string DefaultInstructions = "Respond with JSON that is populated by using the information in this conversation."; + + private readonly IChatCompletionService _service; + private readonly PromptExecutionSettings _executionSettings; + + /// + /// Initializes a new instance of the class. + /// + /// The chat completion service to use for generating responses. + /// The prompt execution settings to use for the chat completion service. + public StructuredOutputTransform(IChatCompletionService service, PromptExecutionSettings executionSettings) + { + Verify.NotNull(service, nameof(service)); + Verify.NotNull(executionSettings, nameof(executionSettings)); + + this._service = service; + this._executionSettings = executionSettings; + } + + /// + /// Gets or sets the instructions to be used as the system message for the chat completion. + /// + public string Instructions { get; init; } = DefaultInstructions; + + /// + /// Transforms the provided into a strongly-typed structured output by invoking the chat completion service and deserializing the response. + /// + /// The chat messages to process. + /// A cancellation token to observe while waiting for the task to complete. + /// The structured output of type . + /// Thrown if the response cannot be deserialized into . + public async ValueTask TransformAsync(IList messages, CancellationToken cancellationToken = default) + { + ChatHistory history = + [ + new ChatMessageContent(AuthorRole.System, this.Instructions), + .. messages, + ]; + ChatMessageContent response = await this._service.GetChatMessageContentAsync(history, this._executionSettings, kernel: null, cancellationToken).ConfigureAwait(false); + return + JsonSerializer.Deserialize(response.Content ?? string.Empty) ?? + throw new InvalidOperationException($"Unable to transform result into {typeof(TOutput).Name}"); + } +} diff --git a/dotnet/src/Agents/Runtime/Abstractions/Runtime.Abstractions.csproj b/dotnet/src/Agents/Runtime/Abstractions/Runtime.Abstractions.csproj index 2e32ccad3c7a..107614183055 100644 --- a/dotnet/src/Agents/Runtime/Abstractions/Runtime.Abstractions.csproj +++ b/dotnet/src/Agents/Runtime/Abstractions/Runtime.Abstractions.csproj @@ -5,11 +5,9 @@ Microsoft.SemanticKernel.Agents.Runtime.Abstractions net8.0;netstandard2.0 $(NoWarn);IDE1006;IDE0130 - preview SKIPSKABSTRACTION + false - - diff --git a/dotnet/src/Agents/Runtime/Core/Runtime.Core.csproj b/dotnet/src/Agents/Runtime/Core/Runtime.Core.csproj index 5607805fa2d3..7b90840b7fb9 100644 --- a/dotnet/src/Agents/Runtime/Core/Runtime.Core.csproj +++ b/dotnet/src/Agents/Runtime/Core/Runtime.Core.csproj @@ -4,12 +4,10 @@ Microsoft.SemanticKernel.Agents.Runtime.Core Microsoft.SemanticKernel.Agents.Runtime.Core net8.0;netstandard2.0 - preview SKIPSKABSTRACTION + false - - diff --git a/dotnet/src/Agents/UnitTests/Agents.UnitTests.csproj b/dotnet/src/Agents/UnitTests/Agents.UnitTests.csproj index a0222fac89cf..d6700e4e4011 100644 --- a/dotnet/src/Agents/UnitTests/Agents.UnitTests.csproj +++ b/dotnet/src/Agents/UnitTests/Agents.UnitTests.csproj @@ -3,20 +3,14 @@ SemanticKernel.Agents.UnitTests SemanticKernel.Agents.UnitTests - net8.0 + net8.0 LatestMajor true false 12 - $(NoWarn);CA2007,CA1812,CA1861,CA1063,CS0618,VSTHRD111,SKEXP0001,SKEXP0050,SKEXP0110;OPENAI001 + $(NoWarn);CA2007,CA1812,CA1861,CA1707,CA1063,CS0618,CS1591,VSTHRD111,SKEXP0001,SKEXP0050,SKEXP0110;OPENAI001 - - - - - - @@ -35,14 +29,17 @@ - - + + - + + + - + + diff --git a/dotnet/src/Agents/UnitTests/MockAgent.cs b/dotnet/src/Agents/UnitTests/MockAgent.cs index bdb5a6dc8868..e986f7e8b6cd 100644 --- a/dotnet/src/Agents/UnitTests/MockAgent.cs +++ b/dotnet/src/Agents/UnitTests/MockAgent.cs @@ -8,6 +8,7 @@ using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Agents; using Microsoft.SemanticKernel.ChatCompletion; +using Moq; namespace SemanticKernel.Agents.UnitTests; @@ -27,6 +28,11 @@ public override IAsyncEnumerable> InvokeAs CancellationToken cancellationToken = default) { this.InvokeCount++; + if (thread == null) + { + Mock mockThread = new(); + thread = mockThread.Object; + } return this.Response.Select(x => new AgentResponseItem(x, thread!)).ToAsyncEnumerable(); } diff --git a/dotnet/src/Agents/UnitTests/Orchestration/ChatGroupExtensionsTests.cs b/dotnet/src/Agents/UnitTests/Orchestration/ChatGroupExtensionsTests.cs new file mode 100644 index 000000000000..211bf716df72 --- /dev/null +++ b/dotnet/src/Agents/UnitTests/Orchestration/ChatGroupExtensionsTests.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.SemanticKernel.Agents.Orchestration.GroupChat; +using Xunit; + +namespace SemanticKernel.Agents.UnitTests.Orchestration; + +public class ChatGroupExtensionsTests +{ + [Fact] + public void FormatNames_WithMultipleAgents_ReturnsCommaSeparatedList() + { + // Arrange + GroupChatTeam group = new() + { + { "AgentOne", ("agent1", "First agent description") }, + { "AgentTwo", ("agent2", "Second agent description") }, + { "AgentThree", ("agent3", "Third agent description") } + }; + + // Act + string result = group.FormatNames(); + + // Assert + Assert.Equal("AgentOne,AgentTwo,AgentThree", result); + } + + [Fact] + public void FormatNames_WithSingleAgent_ReturnsSingleName() + { + // Arrange + GroupChatTeam group = new() + { + { "AgentOne", ("agent1", "First agent description") }, + }; + + // Act + string result = group.FormatNames(); + + // Assert + Assert.Equal("AgentOne", result); + } + + [Fact] + public void FormatNames_WithEmptyGroup_ReturnsEmptyString() + { + // Arrange + GroupChatTeam group = []; + + // Act + string result = group.FormatNames(); + + // Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void FormatList_WithMultipleAgents_ReturnsMarkdownList() + { + // Arrange + GroupChatTeam group = new() + { + { "AgentOne", ("agent1", "First agent description") }, + { "AgentTwo", ("agent2", "Second agent description") }, + { "AgentThree", ("agent3", "Third agent description") } + }; + + // Act + string result = group.FormatList(); + + // Assert + const string Expected = + """ + - AgentOne: First agent description + - AgentTwo: Second agent description + - AgentThree: Third agent description + """; + Assert.Equal(Expected, result); + } + + [Fact] + public void FormatList_WithEmptyGroup_ReturnsEmptyString() + { + // Arrange + GroupChatTeam group = []; + + // Act & Assert + Assert.Equal(string.Empty, group.FormatNames()); + Assert.Equal(string.Empty, group.FormatList()); + } +} diff --git a/dotnet/src/Agents/UnitTests/Orchestration/ConcurrentOrchestrationTests.cs b/dotnet/src/Agents/UnitTests/Orchestration/ConcurrentOrchestrationTests.cs new file mode 100644 index 000000000000..fcf232f3153c --- /dev/null +++ b/dotnet/src/Agents/UnitTests/Orchestration/ConcurrentOrchestrationTests.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.Agents.Orchestration; +using Microsoft.SemanticKernel.Agents.Orchestration.Concurrent; +using Microsoft.SemanticKernel.Agents.Runtime.InProcess; +using Microsoft.SemanticKernel.ChatCompletion; +using Xunit; + +namespace SemanticKernel.Agents.UnitTests.Orchestration; + +/// +/// Tests for the class. +/// +public class ConcurrentOrchestrationTests +{ + [Fact] + public async Task ConcurrentOrchestrationWithSingleAgentAsync() + { + // Arrange + await using InProcessRuntime runtime = new(); + MockAgent mockAgent1 = CreateMockAgent(1, "xyz"); + + // Act: Create and execute the orchestration + string[] response = await ExecuteOrchestrationAsync(runtime, mockAgent1); + + // Assert + Assert.Contains("xyz", response); + Assert.Equal(1, mockAgent1.InvokeCount); + } + + [Fact] + public async Task ConcurrentOrchestrationWithMultipleAgentsAsync() + { + // Arrange + await using InProcessRuntime runtime = new(); + + MockAgent mockAgent1 = CreateMockAgent(1, "abc"); + MockAgent mockAgent2 = CreateMockAgent(2, "xyz"); + MockAgent mockAgent3 = CreateMockAgent(3, "lmn"); + + // Act: Create and execute the orchestration + string[] response = await ExecuteOrchestrationAsync(runtime, mockAgent1, mockAgent2, mockAgent3); + + // Assert + Assert.Contains("lmn", response); + Assert.Contains("xyz", response); + Assert.Contains("abc", response); + Assert.Equal(1, mockAgent1.InvokeCount); + Assert.Equal(1, mockAgent2.InvokeCount); + Assert.Equal(1, mockAgent3.InvokeCount); + } + + private static async Task ExecuteOrchestrationAsync(InProcessRuntime runtime, params Agent[] mockAgents) + { + // Act + await runtime.StartAsync(); + + ConcurrentOrchestration orchestration = new(mockAgents); + + const string InitialInput = "123"; + OrchestrationResult result = await orchestration.InvokeAsync(InitialInput, runtime); + + // Assert + Assert.NotNull(result); + + // Act + string[] response = await result.GetValueAsync(TimeSpan.FromSeconds(20)); + + await runtime.RunUntilIdleAsync(); + + return response; + } + + private static MockAgent CreateMockAgent(int index, string response) + { + return new() + { + Description = $"test {index}", + Response = [new(AuthorRole.Assistant, response)] + }; + } +} diff --git a/dotnet/src/Agents/UnitTests/Orchestration/DefaultTransformsTests.cs b/dotnet/src/Agents/UnitTests/Orchestration/DefaultTransformsTests.cs new file mode 100644 index 000000000000..a223c3d14590 --- /dev/null +++ b/dotnet/src/Agents/UnitTests/Orchestration/DefaultTransformsTests.cs @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents.Orchestration.Transforms; +using Microsoft.SemanticKernel.ChatCompletion; +using Xunit; + +namespace SemanticKernel.Agents.UnitTests.Orchestration; + +public class DefaultTransformsTests +{ + [Fact] + public async Task FromInputAsync_WithEnumerableOfChatMessageContent_ReturnsInputAsync() + { + // Arrange + IEnumerable input = + [ + new(AuthorRole.User, "Hello"), + new(AuthorRole.Assistant, "Hi there") + ]; + + // Act + IEnumerable result = await DefaultTransforms.FromInput(input); + + // Assert + Assert.Equal(input, result); + } + + [Fact] + public async Task FromInputAsync_WithChatMessageContent_ReturnsInputAsListAsync() + { + // Arrange + ChatMessageContent input = new(AuthorRole.User, "Hello"); + + // Act + IEnumerable result = await DefaultTransforms.FromInput(input); + + // Assert + Assert.Single(result); + Assert.Equal(input, result.First()); + } + + [Fact] + public async Task FromInputAsync_WithStringInput_ReturnsUserChatMessageAsync() + { + // Arrange + string input = "Hello, world!"; + + // Act + IEnumerable result = await DefaultTransforms.FromInput(input); + + // Assert + Assert.Single(result); + ChatMessageContent message = result.First(); + Assert.Equal(AuthorRole.User, message.Role); + Assert.Equal(input, message.Content); + } + + [Fact] + public async Task FromInputAsync_WithObjectInput_SerializesAsJsonAsync() + { + // Arrange + TestObject input = new() { Id = 1, Name = "Test" }; + + // Act + IEnumerable result = await DefaultTransforms.FromInput(input); + + // Assert + Assert.Single(result); + ChatMessageContent message = result.First(); + Assert.Equal(AuthorRole.User, message.Role); + + string expectedJson = JsonSerializer.Serialize(input); + Assert.Equal(expectedJson, message.Content); + } + + [Fact] + public async Task ToOutputAsync_WithOutputTypeMatchingInputList_ReturnsSameListAsync() + { + // Arrange + IList input = + [ + new(AuthorRole.User, "Hello"), + new(AuthorRole.Assistant, "Hi there") + ]; + + // Act + IList result = await DefaultTransforms.ToOutput>(input); + + // Assert + Assert.Same(input, result); + } + + [Fact] + public async Task ToOutputAsync_WithOutputTypeChatMessageContent_ReturnsSingleMessageAsync() + { + // Arrange + IList input = + [ + new(AuthorRole.User, "Hello") + ]; + + // Act + ChatMessageContent result = await DefaultTransforms.ToOutput(input); + + // Assert + Assert.Same(input[0], result); + } + + [Fact] + public async Task ToOutputAsync_WithOutputTypeString_ReturnsContentOfSingleMessageAsync() + { + // Arrange + string expected = "Hello, world!"; + IList input = + [ + new(AuthorRole.User, expected) + ]; + + // Act + string result = await DefaultTransforms.ToOutput(input); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public async Task ToOutputAsync_WithOutputTypeDeserializable_DeserializesFromContentAsync() + { + // Arrange + TestObject expected = new() { Id = 42, Name = "TestName" }; + string json = JsonSerializer.Serialize(expected); + IList input = + [ + new(AuthorRole.User, json) + ]; + + // Act + TestObject result = await DefaultTransforms.ToOutput(input); + + // Assert + Assert.Equal(expected.Id, result.Id); + Assert.Equal(expected.Name, result.Name); + } + + [Fact] + public async Task ToOutputAsync_WithInvalidJson_ThrowsExceptionAsync() + { + // Arrange + IList input = + [ + new(AuthorRole.User, "Not valid JSON") + ]; + + // Act & Assert + await Assert.ThrowsAsync(async () => + await DefaultTransforms.ToOutput(input) + ); + } + + [Fact] + public async Task ToOutputAsync_WithMultipleMessagesAndNonMatchingType_ThrowsExceptionAsync() + { + // Arrange + IList input = + [ + new(AuthorRole.User, "Hello"), + new(AuthorRole.Assistant, "Hi there") + ]; + + // Act & Assert + await Assert.ThrowsAsync(async () => + await DefaultTransforms.ToOutput(input) + ); + } + + [Fact] + public async Task ToOutputAsync_WithNullContent_HandlesGracefullyAsync() + { + // Arrange + IList input = + [ + new(AuthorRole.User, (string?)null) + ]; + + // Act + string result = await DefaultTransforms.ToOutput(input); + + // Assert + Assert.Equal(string.Empty, result); + } + + private sealed class TestObject + { + public int Id { get; set; } + public string? Name { get; set; } + } +} diff --git a/dotnet/src/Agents/UnitTests/Orchestration/GroupChatOrchestrationTests.cs b/dotnet/src/Agents/UnitTests/Orchestration/GroupChatOrchestrationTests.cs new file mode 100644 index 000000000000..e0c405eefa33 --- /dev/null +++ b/dotnet/src/Agents/UnitTests/Orchestration/GroupChatOrchestrationTests.cs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.Agents.Orchestration; +using Microsoft.SemanticKernel.Agents.Orchestration.GroupChat; +using Microsoft.SemanticKernel.Agents.Runtime.InProcess; +using Microsoft.SemanticKernel.ChatCompletion; +using Xunit; + +namespace SemanticKernel.Agents.UnitTests.Orchestration; + +/// +/// Tests for the class. +/// +public class GroupChatOrchestrationTests +{ + [Fact] + public async Task GroupChatOrchestrationWithSingleAgentAsync() + { + // Arrange + await using InProcessRuntime runtime = new(); + MockAgent mockAgent1 = CreateMockAgent(2, "xyz"); + + // Act: Create and execute the orchestration + string response = await ExecuteOrchestrationAsync(runtime, mockAgent1); + + // Assert + Assert.Equal("xyz", response); + Assert.Equal(1, mockAgent1.InvokeCount); + } + + [Fact] + public async Task GroupChatOrchestrationWithMultipleAgentsAsync() + { + // Arrange + await using InProcessRuntime runtime = new(); + + MockAgent mockAgent1 = CreateMockAgent(1, "abc"); + MockAgent mockAgent2 = CreateMockAgent(2, "xyz"); + MockAgent mockAgent3 = CreateMockAgent(3, "lmn"); + + // Act: Create and execute the orchestration + string response = await ExecuteOrchestrationAsync(runtime, mockAgent1, mockAgent2, mockAgent3); + + // Assert + Assert.Equal("lmn", response); + Assert.Equal(1, mockAgent1.InvokeCount); + Assert.Equal(1, mockAgent2.InvokeCount); + Assert.Equal(1, mockAgent3.InvokeCount); + } + + private static async Task ExecuteOrchestrationAsync(InProcessRuntime runtime, params Agent[] mockAgents) + { + // Act + await runtime.StartAsync(); + + GroupChatOrchestration orchestration = new(new RoundRobinGroupChatManager() { MaximumInvocationCount = mockAgents.Length }, mockAgents); + + const string InitialInput = "123"; + OrchestrationResult result = await orchestration.InvokeAsync(InitialInput, runtime); + + // Assert + Assert.NotNull(result); + + // Act + string response = await result.GetValueAsync(TimeSpan.FromSeconds(20)); + + await runtime.RunUntilIdleAsync(); + + return response; + } + + private static MockAgent CreateMockAgent(int index, string response) + { + return new() + { + Description = $"test {index}", + Response = [new(AuthorRole.Assistant, response)] + }; + } +} diff --git a/dotnet/src/Agents/UnitTests/Orchestration/HandoffOrchestrationTests.cs b/dotnet/src/Agents/UnitTests/Orchestration/HandoffOrchestrationTests.cs new file mode 100644 index 000000000000..a5f27265b09a --- /dev/null +++ b/dotnet/src/Agents/UnitTests/Orchestration/HandoffOrchestrationTests.cs @@ -0,0 +1,232 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.Agents.Orchestration; +using Microsoft.SemanticKernel.Agents.Orchestration.Handoff; +using Microsoft.SemanticKernel.Agents.Runtime.InProcess; +using Xunit; + +namespace SemanticKernel.Agents.UnitTests.Orchestration; + +/// +/// Tests for the class. +/// +public class HandoffOrchestrationTests : IDisposable +{ + private readonly List _disposables; + + /// + /// Initializes a new instance of the class. + /// + public HandoffOrchestrationTests() + { + this._disposables = []; + } + + /// + public void Dispose() + { + foreach (IDisposable disposable in this._disposables) + { + disposable.Dispose(); + } + GC.SuppressFinalize(this); + } + + [Fact] + public async Task HandoffOrchestrationWithSingleAgentAsync() + { + // Arrange + ChatCompletionAgent mockAgent1 = + this.CreateMockAgent( + "Agent1", + "Test Agent", + Responses.Message("Final response")); + + // Act: Create and execute the orchestration + string response = await ExecuteOrchestrationAsync(OrchestrationHandoffs.StartWith(mockAgent1), mockAgent1); + + // Assert + Assert.Equal("Final response", response); + } + + [Fact] + public async Task HandoffOrchestrationWithMultipleAgentsAsync() + { + // Arrange + ChatCompletionAgent mockAgent1 = + this.CreateMockAgent( + "Agent1", + "Test Agent", + Responses.Handoff("Agent2")); + ChatCompletionAgent mockAgent2 = + this.CreateMockAgent( + "Agent2", + "Test Agent", + Responses.Result("Final response")); + ChatCompletionAgent mockAgent3 = + this.CreateMockAgent( + "Agent3", + "Test Agent", + Responses.Message("Wrong response")); + + // Act: Create and execute the orchestration + string response = await ExecuteOrchestrationAsync( + OrchestrationHandoffs + .StartWith(mockAgent1) + .Add(mockAgent1, mockAgent2, mockAgent3), + mockAgent1, + mockAgent2, + mockAgent3); + + // Assert + Assert.Equal("Final response", response); + } + + private static async Task ExecuteOrchestrationAsync(OrchestrationHandoffs handoffs, params Agent[] mockAgents) + { + // Arrange + await using InProcessRuntime runtime = new(); + await runtime.StartAsync(); + + HandoffOrchestration orchestration = new(handoffs, mockAgents); + + // Act + const string InitialInput = "123"; + OrchestrationResult result = await orchestration.InvokeAsync(InitialInput, runtime); + + // Assert + Assert.NotNull(result); + + // Act + string response = await result.GetValueAsync(TimeSpan.FromSeconds(10)); + await runtime.RunUntilIdleAsync(); + + return response; + } + + private ChatCompletionAgent CreateMockAgent(string name, string description, string response) + { + HttpMessageHandlerStub messageHandlerStub = + new() + { + ResponseToReturn = new HttpResponseMessage + { + StatusCode = System.Net.HttpStatusCode.OK, + Content = new StringContent(response), + }, + }; + HttpClient httpClient = new(messageHandlerStub, disposeHandler: false); + + this._disposables.Add(messageHandlerStub); + this._disposables.Add(httpClient); + + IKernelBuilder builder = Kernel.CreateBuilder(); + builder.AddOpenAIChatCompletion("gpt-test", "mykey", orgId: null, serviceId: null, httpClient); + Kernel kernel = builder.Build(); + + ChatCompletionAgent mockAgent1 = + new() + { + Name = name, + Description = description, + Kernel = kernel, + }; + + return mockAgent1; + } + + private static class Responses + { + public static string Message(string content) => + $$$""" + { + "id": "chat-123", + "object": "chat.completion", + "created": 1699482945, + "model": "gpt-4.1", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "{{{content}}}", + "tool_calls":[] + } + } + ], + "usage": { + "prompt_tokens": 52, + "completion_tokens": 1, + "total_tokens": 53 + } + } + """; + + public static string Handoff(string agentName) => + $$$""" + { + "id": "chat-123", + "object": "chat.completion", + "created": 1699482945, + "model": "gpt-4.1", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls":[{ + "id": "1", + "type": "function", + "function": { + "name": "{{{HandoffInvocationFilter.HandoffPlugin}}}-transfer_to_{{{agentName}}}", + "arguments": "{}" + } + } + ] + } + } + ], + "usage": { + "prompt_tokens": 52, + "completion_tokens": 1, + "total_tokens": 53 + } + } + """; + + public static string Result(string summary) => + $$$""" + { + "id": "chat-123", + "object": "chat.completion", + "created": 1699482945, + "model": "gpt-4.1", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls":[{ + "id": "1", + "type": "function", + "function": { + "name": "{{{HandoffInvocationFilter.HandoffPlugin}}}-end_task_with_summary", + "arguments": "{ \"summary\": \"{{{summary}}}\" }" + } + } + ] + } + } + ] + } + """; + } +} diff --git a/dotnet/src/Agents/UnitTests/Orchestration/HandoffsTests.cs b/dotnet/src/Agents/UnitTests/Orchestration/HandoffsTests.cs new file mode 100644 index 000000000000..edb2643c2498 --- /dev/null +++ b/dotnet/src/Agents/UnitTests/Orchestration/HandoffsTests.cs @@ -0,0 +1,234 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.Agents.Orchestration.Handoff; +using Xunit; + +namespace SemanticKernel.Agents.UnitTests.Orchestration; + +public class HandoffsTests +{ + [Fact] + public void EmptyConstructors_CreateEmptyCollections() + { + AgentHandoffs agentHandoffs = []; + Assert.Empty(agentHandoffs); + + OrchestrationHandoffs orchestrationHandoffs = new("first"); + Assert.Empty(orchestrationHandoffs); + Assert.Equal("first", orchestrationHandoffs.FirstAgentName); + } + + [Fact] + public void DictionaryConstructors_InvalidFirstAgent() + { + Assert.Throws(() => new OrchestrationHandoffs((string)null!)); + Assert.Throws(() => new OrchestrationHandoffs(string.Empty)); + Assert.Throws(() => new OrchestrationHandoffs(" ")); + } + + [Fact] + public void Add_WithAgentObjects_CreatesHandoffRelationships() + { + // Arrange + OrchestrationHandoffs handoffs = new("source"); + + Agent sourceAgent = CreateAgent("source", "Source Agent"); + Agent targetAgent1 = CreateAgent("target1", "Target Agent 1"); + Agent targetAgent2 = CreateAgent("target2", "Target Agent 2"); + + // 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 Add_WithAgentAndCustomDescription_UsesCustomDescription() + { + // Arrange + OrchestrationHandoffs handoffs = new("source"); + + Agent sourceAgent = CreateAgent("source", "Source Agent"); + Agent 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 Add_WithAgentAndTargetName_AddsHandoffWithDescription() + { + // Arrange + OrchestrationHandoffs handoffs = new("source"); + + Agent 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 Add_WithSourceNameAndTargetName_AddsHandoffWithDescription() + { + // 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 Add_WithMultipleSourcesAndTargets_CreatesCorrectStructure() + { + // Arrange + OrchestrationHandoffs handoffs = new("source1"); + + Agent source1 = CreateAgent("source1", "Source Agent 1"); + Agent source2 = CreateAgent("source2", "Source Agent 2"); + + Agent target1 = CreateAgent("target1", "Target Agent 1"); + Agent target2 = CreateAgent("target2", "Target Agent 2"); + Agent 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 StaticAdd_CreatesNewOrchestrationHandoffs() + { + // Arrange + Agent source = CreateAgent("source", "Source Agent"); + Agent target1 = CreateAgent("target1", "Target Agent 1"); + Agent target2 = CreateAgent("target2", "Target Agent 2"); + + // Act + OrchestrationHandoffs handoffs = + OrchestrationHandoffs + .StartWith(source) + .Add(source, target1, target2); + + // 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"]); + } + + [Fact] + public void Add_WithAgentsWithNoNameUsesId() + { + // Arrange + OrchestrationHandoffs handoffs = new("source-id"); + + Agent sourceAgent = CreateAgent(id: "source-id", name: null); + Agent targetAgent = CreateAgent(id: "target-id", name: null, description: "Target Description"); + + // Act + 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"]); + } + + [Fact] + public void Add_WithTargetWithNoDescription_UsesEmptyString() + { + // Arrange + OrchestrationHandoffs handoffs = new("source"); + + Agent sourceAgent = CreateAgent("source", "Source Agent"); + Agent targetAgent = CreateAgent("target", null); + + // Act + handoffs.Add(sourceAgent, targetAgent); + + // Assert + Assert.Single(handoffs); + Assert.Equal("source", handoffs.FirstAgentName); + AgentHandoffs sourceHandoffs = handoffs["source"]; + Assert.Single(sourceHandoffs); + Assert.Equal(string.Empty, sourceHandoffs["target"]); + } + + private static ChatCompletionAgent CreateAgent(string id, string? description = null, string? name = null) + { + ChatCompletionAgent mockAgent = + new() + { + Id = id, + Description = description, + Name = name, + }; + + return mockAgent; + } +} diff --git a/dotnet/src/Agents/UnitTests/Orchestration/OrchestrationResultTests.cs b/dotnet/src/Agents/UnitTests/Orchestration/OrchestrationResultTests.cs new file mode 100644 index 000000000000..fd8721482ceb --- /dev/null +++ b/dotnet/src/Agents/UnitTests/Orchestration/OrchestrationResultTests.cs @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.SemanticKernel.Agents.Orchestration; +using Microsoft.SemanticKernel.Agents.Runtime; +using Xunit; + +namespace SemanticKernel.Agents.UnitTests.Orchestration; + +public class OrchestrationResultTests +{ + [Fact] + public void Constructor_InitializesPropertiesCorrectly() + { + // Arrange + OrchestrationContext context = new("TestOrchestration", new TopicId("testTopic"), null, NullLoggerFactory.Instance, CancellationToken.None); + TaskCompletionSource tcs = new(); + + // Act + using CancellationTokenSource cancelSource = new(); + using OrchestrationResult result = new(context, tcs, cancelSource, NullLogger.Instance); + + // Assert + Assert.Equal("TestOrchestration", result.Orchestration); + Assert.Equal(new TopicId("testTopic"), result.Topic); + } + + [Fact] + public async Task GetValueAsync_ReturnsCompletedValue_WhenTaskIsCompletedAsync() + { + // Arrange + OrchestrationContext context = new("TestOrchestration", new TopicId("testTopic"), null, NullLoggerFactory.Instance, CancellationToken.None); + TaskCompletionSource tcs = new(); + using CancellationTokenSource cancelSource = new(); + using OrchestrationResult result = new(context, tcs, cancelSource, NullLogger.Instance); + string expectedValue = "Result value"; + + // Act + tcs.SetResult(expectedValue); + string actualValue = await result.GetValueAsync(); + + // Assert + Assert.Equal(expectedValue, actualValue); + } + + [Fact] + public async Task GetValueAsync_WithTimeout_ReturnsCompletedValue_WhenTaskCompletesWithinTimeoutAsync() + { + // Arrange + OrchestrationContext context = new("TestOrchestration", new TopicId("testTopic"), null, NullLoggerFactory.Instance, CancellationToken.None); + TaskCompletionSource tcs = new(); + using CancellationTokenSource cancelSource = new(); + using OrchestrationResult result = new(context, tcs, cancelSource, NullLogger.Instance); + string expectedValue = "Result value"; + TimeSpan timeout = TimeSpan.FromSeconds(1); + + // Act + tcs.SetResult(expectedValue); + string actualValue = await result.GetValueAsync(timeout); + + // Assert + Assert.Equal(expectedValue, actualValue); + } + + [Fact] + public async Task GetValueAsync_WithTimeout_ThrowsTimeoutException_WhenTaskDoesNotCompleteWithinTimeoutAsync() + { + // Arrange + OrchestrationContext context = new("TestOrchestration", new TopicId("testTopic"), null, NullLoggerFactory.Instance, CancellationToken.None); + TaskCompletionSource tcs = new(); + using CancellationTokenSource cancelSource = new(); + using OrchestrationResult result = new(context, tcs, cancelSource, NullLogger.Instance); + TimeSpan timeout = TimeSpan.FromMilliseconds(50); + + // Act & Assert + TimeoutException exception = await Assert.ThrowsAsync(() => result.GetValueAsync(timeout).AsTask()); + Assert.Contains("Orchestration did not complete within the allowed duration", exception.Message); + } + + [Fact] + public async Task GetValueAsync_ReturnsCompletedValue_WhenCompletionIsDelayedAsync() + { + // Arrange + OrchestrationContext context = new("TestOrchestration", new TopicId("testTopic"), null, NullLoggerFactory.Instance, CancellationToken.None); + TaskCompletionSource tcs = new(); + using CancellationTokenSource cancelSource = new(); + using OrchestrationResult result = new(context, tcs, cancelSource, NullLogger.Instance); + int expectedValue = 42; + + // Act + // Simulate delayed completion in a separate task + Task delayTask = Task.Run(async () => + { + await Task.Delay(100); + tcs.SetResult(expectedValue); + }); + + int actualValue = await result.GetValueAsync(); + + // Assert + Assert.Equal(expectedValue, actualValue); + } +} diff --git a/dotnet/src/Agents/UnitTests/Orchestration/SequentialOrchestrationTests.cs b/dotnet/src/Agents/UnitTests/Orchestration/SequentialOrchestrationTests.cs new file mode 100644 index 000000000000..3840365a69d6 --- /dev/null +++ b/dotnet/src/Agents/UnitTests/Orchestration/SequentialOrchestrationTests.cs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.Agents.Orchestration; +using Microsoft.SemanticKernel.Agents.Orchestration.Sequential; +using Microsoft.SemanticKernel.Agents.Runtime.InProcess; +using Microsoft.SemanticKernel.ChatCompletion; +using Xunit; + +namespace SemanticKernel.Agents.UnitTests.Orchestration; + +/// +/// Tests for the class. +/// +public class SequentialOrchestrationTests +{ + [Fact] + public async Task SequentialOrchestrationWithSingleAgentAsync() + { + // Arrange + await using InProcessRuntime runtime = new(); + MockAgent mockAgent1 = CreateMockAgent(2, "xyz"); + + // Act: Create and execute the orchestration + string response = await ExecuteOrchestrationAsync(runtime, mockAgent1); + + // Assert + Assert.Equal("xyz", response); + Assert.Equal(1, mockAgent1.InvokeCount); + } + + [Fact] + public async Task SequentialOrchestrationWithMultipleAgentsAsync() + { + // Arrange + await using InProcessRuntime runtime = new(); + + MockAgent mockAgent1 = CreateMockAgent(1, "abc"); + MockAgent mockAgent2 = CreateMockAgent(2, "xyz"); + MockAgent mockAgent3 = CreateMockAgent(3, "lmn"); + + // Act: Create and execute the orchestration + string response = await ExecuteOrchestrationAsync(runtime, mockAgent1, mockAgent2, mockAgent3); + + // Assert + Assert.Equal("lmn", response); + Assert.Equal(1, mockAgent1.InvokeCount); + Assert.Equal(1, mockAgent2.InvokeCount); + Assert.Equal(1, mockAgent3.InvokeCount); + } + + private static async Task ExecuteOrchestrationAsync(InProcessRuntime runtime, params Agent[] mockAgents) + { + // Act + await runtime.StartAsync(); + + SequentialOrchestration orchestration = new(mockAgents); + + const string InitialInput = "123"; + OrchestrationResult result = await orchestration.InvokeAsync(InitialInput, runtime); + + // Assert + Assert.NotNull(result); + + // Act + string response = await result.GetValueAsync(TimeSpan.FromSeconds(20)); + + await runtime.RunUntilIdleAsync(); + + return response; + } + + private static MockAgent CreateMockAgent(int index, string response) + { + return new() + { + Description = $"test {index}", + Response = [new(AuthorRole.Assistant, response)] + }; + } +} diff --git a/dotnet/src/InternalUtilities/samples/AgentUtilities/BaseOrchestrationTest.cs b/dotnet/src/InternalUtilities/samples/AgentUtilities/BaseOrchestrationTest.cs new file mode 100644 index 000000000000..0065c68e5934 --- /dev/null +++ b/dotnet/src/InternalUtilities/samples/AgentUtilities/BaseOrchestrationTest.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.ChatCompletion; + +/// +/// Base class for samples that demonstrate the usage of host agents +/// based on API's such as Open AI Assistants or Azure AI Agents. +/// +public abstract class BaseOrchestrationTest(ITestOutputHelper output) : BaseAgentsTest(output) +{ + protected const int ResultTimeoutInSeconds = 30; + + protected ChatCompletionAgent CreateAgent(string instructions, string? name = null, string? description = null) + { + return + new ChatCompletionAgent + { + Instructions = instructions, + Name = name, + Description = description, + Kernel = this.CreateKernelWithChatCompletion(), + }; + } + + protected sealed class OrchestrationMonitor + { + public ChatHistory History { get; } = []; + + public ValueTask ResponseCallback(ChatMessageContent response) + { + this.History.Add(response); + return ValueTask.CompletedTask; + } + } +} diff --git a/dotnet/src/SemanticKernel.Abstractions/CompatibilitySuppressions.xml b/dotnet/src/SemanticKernel.Abstractions/CompatibilitySuppressions.xml index 195237c69926..f925c94bf1c3 100644 --- a/dotnet/src/SemanticKernel.Abstractions/CompatibilitySuppressions.xml +++ b/dotnet/src/SemanticKernel.Abstractions/CompatibilitySuppressions.xml @@ -71,6 +71,13 @@ lib/net8.0/Microsoft.SemanticKernel.Abstractions.dll true + + CP0002 + M:Microsoft.SemanticKernel.Agents.StreamingAnnotationContent.set_FileId(System.String) + lib/net8.0/Microsoft.SemanticKernel.Abstractions.dll + lib/net8.0/Microsoft.SemanticKernel.Abstractions.dll + true + CP0002 M:Microsoft.SemanticKernel.Agents.StreamingAnnotationContent.set_Quote(System.String) @@ -190,6 +197,13 @@ lib/netstandard2.0/Microsoft.SemanticKernel.Abstractions.dll true + + CP0002 + M:Microsoft.SemanticKernel.Agents.StreamingAnnotationContent.set_FileId(System.String) + lib/netstandard2.0/Microsoft.SemanticKernel.Abstractions.dll + lib/netstandard2.0/Microsoft.SemanticKernel.Abstractions.dll + true + CP0002 M:Microsoft.SemanticKernel.Agents.StreamingAnnotationContent.set_Quote(System.String) diff --git a/dotnet/src/SemanticKernel.Abstractions/Contents/StreamingAnnotationContent.cs b/dotnet/src/SemanticKernel.Abstractions/Contents/StreamingAnnotationContent.cs index 60b2145d907d..c6e339f7e864 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Contents/StreamingAnnotationContent.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Contents/StreamingAnnotationContent.cs @@ -24,7 +24,6 @@ public class StreamingAnnotationContent : StreamingKernelContent public string FileId { get => this.ReferenceId; - init => this.ReferenceId = value; } /// @@ -40,11 +39,12 @@ public string FileId /// /// Provides context for using . /// - public AnnotationKind Kind { get; init; } + public AnnotationKind Kind { get; } /// /// The citation. /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Label { get; init; } /// @@ -54,8 +54,7 @@ public string FileId /// A file is referenced for certain tools, such as file search, and also when /// and image or document is produced as part of the agent response. /// - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string ReferenceId { get; init; } + public string ReferenceId { get; } /// /// The title of the annotation reference (when == .. @@ -80,6 +79,7 @@ public string FileId /// /// Describes the kind of annotation /// Identifies the referenced resource. + [JsonConstructor] public StreamingAnnotationContent( AnnotationKind kind, string referenceId)