From 7c8ec5ec19c693998ce1be44200ecaf00905d0cc Mon Sep 17 00:00:00 2001 From: Chris <66376200+crickman@users.noreply.github.com> Date: Tue, 8 Jul 2025 10:04:34 -0700 Subject: [PATCH] .NET Port Agent Orchestration (#107) * Checkpoint * Checkpoint * Namespaces * Namespace * Cleanup * Namespace order * Fix sync * Formatting * Formatting * Namespace * Namespace order * Code convention * Naming * Naming * Text handling * Text handling * Namespace * Namespace order * Namespace ordering * Test * ValueTask * net472 * Test fix * Fix namespace (net472) * Namespace * Fix conditional namespace * Fix type expression * Compatibility and cleanup * Sample compatibility * Sample compat * Test compat * modifier order * Simply http-stub * Formating fix for unit-test * Fix test * Real fix * Test clean-up * Update dotnet/src/Microsoft.Agents.Orchestration/Handoff/HandoffOrchestration.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix build errors after merging --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Stephen Toub --- .vs/VSWorkspaceState.json | 7 + .vs/af1/v17/.wsuo | Bin 0 -> 12288 bytes .vs/af1/v17/DocumentLayout.json | 27 ++ dotnet/Directory.Packages.props | 7 + dotnet/agent-framework-dotnet.slnx | 243 +++++++++-------- .../GettingStarted/GettingStarted.csproj | 5 + .../ConcurrentOrchestration_Intro.cs | 61 +++++ ...rentOrchestration_With_StructuredOutput.cs | 70 +++++ .../GroupChatOrchestration_Intro.cs | 85 ++++++ .../GroupChatOrchestration_With_AIManager.cs | 207 ++++++++++++++ ...upChatOrchestration_With_HumanInTheLoop.cs | 119 ++++++++ .../HandoffOrchestration_Intro.cs | 109 ++++++++ ...ndoffOrchestration_With_StructuredInput.cs | 124 +++++++++ .../SequentialOrchestration_Intro.cs | 82 ++++++ ...quentialOrchestration_With_Cancellation.cs | 54 ++++ .../Resources/Hamlet_full_play_summary.txt | 13 + .../AgentActor.cs | 170 ++++++++++++ .../AgentOrchestration.RequestActor.cs | 75 +++++ .../AgentOrchestration.ResultActor.cs | 80 ++++++ .../AgentOrchestration.cs | 257 ++++++++++++++++++ .../Concurrent/ConcurrentActor.cs | 45 +++ .../Concurrent/ConcurrentMessages.cs | 54 ++++ .../ConcurrentOrchestration.String.cs | 33 +++ .../Concurrent/ConcurrentOrchestration.cs | 84 ++++++ .../Concurrent/ConcurrentResultActor.cs | 59 ++++ .../Extensions/RuntimeExtensions.cs | 64 +++++ .../GroupChat/GroupChatAgentActor.cs | 74 +++++ .../GroupChat/GroupChatManager.cs | 106 ++++++++ .../GroupChat/GroupChatManagerActor.cs | 101 +++++++ .../GroupChat/GroupChatMessages.cs | 85 ++++++ .../GroupChatOrchestration.String.cs | 21 ++ .../GroupChat/GroupChatOrchestration.cs | 100 +++++++ .../GroupChat/GroupChatTeam.cs | 32 +++ .../GroupChat/RoundRobinGroupChatManager.cs | 55 ++++ .../Handoff/HandoffActor.cs | 211 ++++++++++++++ .../Handoff/HandoffMessages.cs | 65 +++++ .../Handoff/HandoffOrchestration.String.cs | 22 ++ .../Handoff/HandoffOrchestration.cs | 114 ++++++++ .../Handoff/Handoffs.cs | 147 ++++++++++ .../Logging/AgentOrchestrationLogMessages.cs | 157 +++++++++++ .../ConcurrentOrchestrationLogMessages.cs | 49 ++++ .../GroupChatOrchestrationLogMessages.cs | 100 +++++++ .../HandoffOrchestrationLogMessages.cs | 54 ++++ .../Logging/OrchestrationResultLogMessages.cs | 66 +++++ .../SequentialOrchestrationLogMessages.cs | 36 +++ .../Microsoft.Agents.Orchestration/Marker.cs | 10 + .../Microsoft.Agents.Orchestration.csproj | 37 +++ .../OrchestrationActor.cs | 44 +++ .../OrchestrationContext.cs | 62 +++++ .../OrchestrationResult.cs | 125 +++++++++ .../Sequential/SequentialActor.cs | 63 +++++ .../Sequential/SequentialMessages.cs | 60 ++++ .../SequentialOrchestration.String.cs | 21 ++ .../Sequential/SequentialOrchestration.cs | 72 +++++ .../Transforms/DefaultTransforms.cs | 79 ++++++ .../Transforms/OrchestrationTransforms.cs | 34 +++ .../Transforms/StructuredOutputTransform.cs | 58 ++++ dotnet/src/Shared/Samples/BaseSample.cs | 33 ++- .../src/Shared/Samples/OrchestrationSample.cs | 181 ++++++++++++ dotnet/src/Shared/Samples/Resources.cs | 13 + .../ChatGroupExtensionsTests.cs | 86 ++++++ .../ConcurrentOrchestrationTests.cs | 73 +++++ .../DefaultTransformsTests.cs | 201 ++++++++++++++ .../GroupChatOrchestrationTests.cs | 71 +++++ .../HandoffOrchestrationTests.cs | 243 +++++++++++++++++ .../HandoffsTests.cs | 236 ++++++++++++++++ .../HttpMessageHandlerStub.cs | 18 ++ ...soft.Agents.Orchestration.UnitTests.csproj | 18 ++ .../MockAgent.cs | 64 +++++ .../OrchestrationResultTests.cs | 104 +++++++ .../SequentialOrchestrationTests.cs | 71 +++++ 71 files changed, 5675 insertions(+), 131 deletions(-) create mode 100644 .vs/VSWorkspaceState.json create mode 100644 .vs/af1/v17/.wsuo create mode 100644 .vs/af1/v17/DocumentLayout.json create mode 100644 dotnet/samples/GettingStarted/Orchestration/ConcurrentOrchestration_Intro.cs create mode 100644 dotnet/samples/GettingStarted/Orchestration/ConcurrentOrchestration_With_StructuredOutput.cs create mode 100644 dotnet/samples/GettingStarted/Orchestration/GroupChatOrchestration_Intro.cs create mode 100644 dotnet/samples/GettingStarted/Orchestration/GroupChatOrchestration_With_AIManager.cs create mode 100644 dotnet/samples/GettingStarted/Orchestration/GroupChatOrchestration_With_HumanInTheLoop.cs create mode 100644 dotnet/samples/GettingStarted/Orchestration/HandoffOrchestration_Intro.cs create mode 100644 dotnet/samples/GettingStarted/Orchestration/HandoffOrchestration_With_StructuredInput.cs create mode 100644 dotnet/samples/GettingStarted/Orchestration/SequentialOrchestration_Intro.cs create mode 100644 dotnet/samples/GettingStarted/Orchestration/SequentialOrchestration_With_Cancellation.cs create mode 100644 dotnet/samples/GettingStarted/Resources/Hamlet_full_play_summary.txt create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/AgentActor.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/AgentOrchestration.RequestActor.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/AgentOrchestration.ResultActor.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/AgentOrchestration.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentActor.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentMessages.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentOrchestration.String.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentOrchestration.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentResultActor.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Extensions/RuntimeExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatAgentActor.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatManager.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatManagerActor.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatMessages.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatOrchestration.String.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatOrchestration.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatTeam.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/GroupChat/RoundRobinGroupChatManager.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Handoff/HandoffActor.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Handoff/HandoffMessages.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Handoff/HandoffOrchestration.String.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Handoff/HandoffOrchestration.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Handoff/Handoffs.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Logging/AgentOrchestrationLogMessages.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Logging/ConcurrentOrchestrationLogMessages.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Logging/GroupChatOrchestrationLogMessages.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Logging/HandoffOrchestrationLogMessages.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Logging/OrchestrationResultLogMessages.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Logging/SequentialOrchestrationLogMessages.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Marker.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Microsoft.Agents.Orchestration.csproj create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/OrchestrationActor.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/OrchestrationContext.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/OrchestrationResult.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Sequential/SequentialActor.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Sequential/SequentialMessages.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Sequential/SequentialOrchestration.String.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Sequential/SequentialOrchestration.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Transforms/DefaultTransforms.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Transforms/OrchestrationTransforms.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Transforms/StructuredOutputTransform.cs create mode 100644 dotnet/src/Shared/Samples/OrchestrationSample.cs create mode 100644 dotnet/src/Shared/Samples/Resources.cs create mode 100644 dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/ChatGroupExtensionsTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/ConcurrentOrchestrationTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/DefaultTransformsTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/GroupChatOrchestrationTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/HandoffOrchestrationTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/HandoffsTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/HttpMessageHandlerStub.cs create mode 100644 dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/Microsoft.Agents.Orchestration.UnitTests.csproj create mode 100644 dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/MockAgent.cs create mode 100644 dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/OrchestrationResultTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/SequentialOrchestrationTests.cs diff --git a/.vs/VSWorkspaceState.json b/.vs/VSWorkspaceState.json new file mode 100644 index 0000000000..501b8efb8a --- /dev/null +++ b/.vs/VSWorkspaceState.json @@ -0,0 +1,7 @@ +{ + "ExpandedNodes": [ + "" + ], + "SelectedNode": "\\agent-framework-dotnet.slnx", + "PreviewInSolutionExplorer": false +} \ No newline at end of file diff --git a/.vs/af1/v17/.wsuo b/.vs/af1/v17/.wsuo new file mode 100644 index 0000000000000000000000000000000000000000..920a221516479574db0599aa9ad2f4416e762cde GIT binary patch literal 12288 zcmeHN&u<&Y6&~5KV7dGF1enf>C<#J~Ua>GS^)Ep$;F6?gBAi{p{>u}EBeSMV>GcK6=Bdwj(>q9b~Q zIItx={6aA))`W|@FRC2M*mOo5YmE7KdX)Ms^_yRbFaJ6AB=eoDW!?m$C*Bj6fo>tT zCiZ*Oa`5#TaioDywjX@k{^<=QQ+7!jpSXL%K?{5#5%S=Lfgo$yI$o#5k+JA-cgz@h z@e1O*_2;Xi0u)Es=s&})2=XdY{7AWWQQ|*_7sn9d_WvC6YQ3I7{HtgO5I?mml4~xg2|3rs((R`!$!{-2fwj&5f5%fI5 z@uxg+3^|q@XR0g5pK_q>KXr)n2W>smyM-UL$urrKHWhjP5#p~$p4-*^@VWQsw_pXn zham#|w&86^@xEAr4@6H;s%wBT=tl5A4%mQhdG$MvrH-!xrwAYD7Jl@*c9hpu67S-v z7dGpd9031il%wxLYQA6`fQ>5EYpV#PklNwbWY0t^ zOtl{9)}9RTX|J9`h}-`kB5x=Bbv5QDyl{@VuXus%9f|#??WA8E7ykw1?IeFq{HiEJ zGd$>LHyVWhQTRA98UPa&-kn-q#slE!njlNRr ze34Z0@2C-|S%D-*$irBfs+ASRE0(_q{O5819ztCHPb079e?Rz~r$RvsZ2+HhgbT2^qpx#V#C~f!tUCLtM`QEePdVm|%KL`mt5Khclkf;A3!&1{Vnah&!bIx3N;kp( zG1^nzhB)}N)!P1b!{1R}fGe(IHllWNK4>M(zYXlT{pWf{um8H?uR#7OaUQH=(+Bt& zxI}AFvrLqKZO`N4(^sIqBE`iopcR}8Xp5qL$a(btH0sfNG^)}b7xG@?mlhJaTxK?r zE@tNvxp^~}n9XEo(izh*XXlI$n2pn0M|%5?_GCI{>H1;{`h0K(3gFXiiy8b$v zCpRla$1c4ks|9aYx)+KwbJo1IV5L&#{ERVYoTOy!^@K`Zc0=h~Mo9*TfqINRUF?mz z;<=&kIga#`TNO7i!_anb1*=AA(ASw-yd13Cj;yCIl|uWrytsSw=CV=RMaFeoI_4{j zC`q0Lm)%VREODgMnkS8l@}5%(!OI=nlqeC@u~>imik~(V-kN*c_C2>O-7t7@v0(VO zWVq=Vq2>AIR-EY#F9^3}33;9BwLqGzU3KRi22}1}wXPUJxaLUKcA<0T#j;;n@ycbx zH4Ab-ykZAoD-_ss^f7UKY;5eQd^HGVIhhZA@Z_BvOTF2?wjWA2u#2{1ht=fTzAP!$ z=x$MDPEbTa<7&g=e-Pf`>!naUe%o*=^5#u(f+%$mG0x@1g!UIE_{7(zj@EWCAEN#! zNzV0VX+)C2#;SuMqZqS;4^k?Q>v0p~WZ(oZoIFlFQR5_{{gYaJ^k1w|f)kuJeT~FE zBEy!aA2JL+=W>>B*0Fa9kFjak_QwY5l2L0~G|Mn)(R#k__*jJVgJ5a9GDumL;kdtv zup-Wj>*5-`Qu^!x;w9W|Tx)B47byWeExo649dEhI!xipzc(fM$tm~|>Pog<|N8ISM zR5N>1ZkuWS#ArZjzI;@YuOU@&hHhl^muNh7k44Kc-jVWR>dCK`&i(dpZ+`lZkM5lQ za0i=ld6BiA`TXwZXRrMEkLF+gaQ45sj>XkJ0CjjOn_b9cOO}~Pr;Su1ZCLY(qB%R0 zFlOgdnW81r^F>2-s$uc$OPr*Z6ZyjV8T@`JQ^f@{Wn~k&;#?__&J|OMtd+JBMXQvV zGqTz2+`RcgGrPHv&7@|@CF&ZDv6@bC=a@bU`~Td>8qshZXkC4c_W!u&Z^AB)_W!;O z`+pl^3$y0refyo0f;;g}W1j~mu`OZ$z3=@eet*E(>aZwk0s5`8-T6zm^On~72RVNj zo&PlVKU?`f==l$MN;zm9pLWmD^PkcFUyVb9?%xf&|3^E(^IMMGAp4KQ?jN?w-v%sR z8M7b11C8!YcRGig#{M_uj^B60l|P;r6%a_R_%+Pht~*yKOIV}q;e3qWB=97TEsGM* ze~J5jB*w*OUcK(h1A`}@JWV1Bk++aW8_4s(pJTe z^)Jff*7KK5pt|_+++!VWh@Ly>+Sz~o4NhEqp4I93r+^do1V&*M`|t}mZ_FU1vEW%l zSjIJndj@O6RWXa}67D(N=aFYYH38=zitnoPG5gQ+3cbEBD83#%cTM6vl3%4%ajl&S zui}Z{3qyPW_ literal 0 HcmV?d00001 diff --git a/.vs/af1/v17/DocumentLayout.json b/.vs/af1/v17/DocumentLayout.json new file mode 100644 index 0000000000..d15424fbb6 --- /dev/null +++ b/.vs/af1/v17/DocumentLayout.json @@ -0,0 +1,27 @@ +{ + "Version": 1, + "WorkspaceRootPath": "C:\\Users\\crickman\\source\\repos\\af1\\", + "Documents": [], + "DocumentGroupContainers": [ + { + "Orientation": 0, + "VerticalTabListWidth": 256, + "DocumentGroups": [ + { + "DockedWidth": 200, + "SelectedChildIndex": -1, + "Children": [ + { + "$type": "Bookmark", + "Name": "ST:0:0:{e1b7d1f8-9b3c-49b1-8f4f-bfc63a88835d}" + }, + { + "$type": "Bookmark", + "Name": "ST:0:0:{d78612c7-9962-4b83-95d9-268046dad23a}" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 6bb7c4680c..95442bacb1 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -23,8 +23,15 @@ + + + + + + + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 05c181502c..0ed2cbaa4e 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -1,120 +1,123 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/GettingStarted.csproj b/dotnet/samples/GettingStarted/GettingStarted.csproj index 292b9fa311..5e096b6b48 100644 --- a/dotnet/samples/GettingStarted/GettingStarted.csproj +++ b/dotnet/samples/GettingStarted/GettingStarted.csproj @@ -26,6 +26,7 @@ + @@ -36,8 +37,12 @@ + + Always + Always + diff --git a/dotnet/samples/GettingStarted/Orchestration/ConcurrentOrchestration_Intro.cs b/dotnet/samples/GettingStarted/Orchestration/ConcurrentOrchestration_Intro.cs new file mode 100644 index 0000000000..b50aa01414 --- /dev/null +++ b/dotnet/samples/GettingStarted/Orchestration/ConcurrentOrchestration_Intro.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.Orchestration; +using Microsoft.Agents.Orchestration.Concurrent; +using Microsoft.Extensions.AI.Agents; +using Microsoft.SemanticKernel.Agents.Runtime.InProcess; + +namespace Orchestration; + +/// +/// Demonstrates how to use the +/// for executing multiple agents on the same task in parallel. +/// +public class ConcurrentOrchestration_Intro(ITestOutputHelper output) : OrchestrationSample(output) +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RunOrchestrationAsync(bool streamedResponse) + { + // Define the agents + ChatClientAgent physicist = + this.CreateAgent( + instructions: "You are an expert in physics. You answer questions from a physics perspective.", + description: "An expert in physics"); + ChatClientAgent chemist = + this.CreateAgent( + instructions: "You are an expert in chemistry. You answer questions from a chemistry perspective.", + description: "An expert in chemistry"); + + // Create a monitor to capturing agent responses (via ResponseCallback) + // to display at the end of this sample. (optional) + // NOTE: Create your own callback to capture responses in your application or service. + OrchestrationMonitor monitor = new(); + + // Define the orchestration + ConcurrentOrchestration orchestration = + new(physicist, chemist) + { + LoggerFactory = this.LoggerFactory, + ResponseCallback = monitor.ResponseCallback, + StreamingResponseCallback = streamedResponse ? monitor.StreamingResultCallback : null, + }; + + // Start the runtime + await using 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(); + + this.DisplayHistory(monitor.History); + } +} diff --git a/dotnet/samples/GettingStarted/Orchestration/ConcurrentOrchestration_With_StructuredOutput.cs b/dotnet/samples/GettingStarted/Orchestration/ConcurrentOrchestration_With_StructuredOutput.cs new file mode 100644 index 0000000000..3ff1922f12 --- /dev/null +++ b/dotnet/samples/GettingStarted/Orchestration/ConcurrentOrchestration_With_StructuredOutput.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Agents.Orchestration; +using Microsoft.Agents.Orchestration.Concurrent; +using Microsoft.Agents.Orchestration.Transforms; +using Microsoft.Extensions.AI.Agents; +using Microsoft.SemanticKernel.Agents.Runtime.InProcess; +using Microsoft.Shared.Samples; + +namespace Orchestration; + +/// +/// Demonstrates how to use the with structured output. +/// +public class ConcurrentOrchestration_With_StructuredOutput(ITestOutputHelper output) : OrchestrationSample(output) +{ + private static readonly JsonSerializerOptions s_options = new() { WriteIndented = true }; + + [Fact] + public async Task RunOrchestrationAsync() + { + // Define the agents + ChatClientAgent 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"); + ChatClientAgent agent2 = + this.CreateAgent( + instructions: "You are an expert in sentiment analysis. Given an article, identify the sentiment.", + description: "An expert in sentiment analysis"); + ChatClientAgent 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 + StructuredOutputTransform outputTransform = new(this.CreateChatClient()); + ConcurrentOrchestration orchestration = + new(agent1, agent2, agent3) + { + LoggerFactory = this.LoggerFactory, + ResultTransform = outputTransform.TransformAsync, + }; + + // Start the runtime + await using InProcessRuntime runtime = new(); + await runtime.StartAsync(); + + // Run the orchestration + const string resourceId = "Hamlet_full_play_summary.txt"; + string input = Resources.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(); + } + +#pragma warning disable CA1812 // Avoid uninstantiated internal classes + private sealed class Analysis + { + public IList Themes { get; set; } = []; + public IList Sentiments { get; set; } = []; + public IList Entities { get; set; } = []; + } +#pragma warning restore CA1812 // Avoid uninstantiated internal classes +} diff --git a/dotnet/samples/GettingStarted/Orchestration/GroupChatOrchestration_Intro.cs b/dotnet/samples/GettingStarted/Orchestration/GroupChatOrchestration_Intro.cs new file mode 100644 index 0000000000..935e1e0848 --- /dev/null +++ b/dotnet/samples/GettingStarted/Orchestration/GroupChatOrchestration_Intro.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.Orchestration; +using Microsoft.Agents.Orchestration.GroupChat; +using Microsoft.Extensions.AI.Agents; +using Microsoft.SemanticKernel.Agents.Runtime.InProcess; + +namespace 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 GroupChatOrchestration_Intro(ITestOutputHelper output) : OrchestrationSample(output) +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RunOrchestrationAsync(bool streamedResponse) + { + // Define the agents + ChatClientAgent 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. + """); + ChatClientAgent 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. + """); + + // Create a monitor to capturing agent responses (via ResponseCallback) + // to display at the end of this sample. (optional) + // NOTE: Create your own callback to capture responses in your application or service. + OrchestrationMonitor monitor = new(); + // Define the orchestration + GroupChatOrchestration orchestration = + new(new RoundRobinGroupChatManager() + { + MaximumInvocationCount = 5 + }, + writer, + editor) + { + LoggerFactory = this.LoggerFactory, + ResponseCallback = monitor.ResponseCallback, + StreamingResponseCallback = streamedResponse ? monitor.StreamingResultCallback : null, + }; + + // Start the runtime + await using 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(); + + this.DisplayHistory(monitor.History); + } +} diff --git a/dotnet/samples/GettingStarted/Orchestration/GroupChatOrchestration_With_AIManager.cs b/dotnet/samples/GettingStarted/Orchestration/GroupChatOrchestration_With_AIManager.cs new file mode 100644 index 0000000000..45dec54c6a --- /dev/null +++ b/dotnet/samples/GettingStarted/Orchestration/GroupChatOrchestration_With_AIManager.cs @@ -0,0 +1,207 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.Orchestration; +using Microsoft.Agents.Orchestration.GroupChat; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; +using Microsoft.SemanticKernel.Agents.Runtime.InProcess; + +namespace 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 GroupChatOrchestration_With_AIManager(ITestOutputHelper output) : OrchestrationSample(output) +{ + [Fact] + public async Task RunOrchestrationAsync() + { + // Define the agents + ChatClientAgent 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. + """); + ChatClientAgent 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. + """); + ChatClientAgent 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. + """); + ChatClientAgent 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. + """); + ChatClientAgent 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. + """); + ChatClientAgent 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. + """); + ChatClientAgent 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. + """); + ChatClientAgent 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. + """); + + // Create a monitor to capturing agent responses (via ResponseCallback) + // to display at the end of this sample. (optional) + // NOTE: Create your own callback to capture responses in your application or service. + OrchestrationMonitor monitor = new(); + + // Define the orchestration + const string topic = "What does a good life mean to you personally?"; + GroupChatOrchestration orchestration = + new( + new AIGroupChatManager( + topic, + this.CreateChatClient()) + { + MaximumInvocationCount = 5 + }, + farmer, + developer, + teacher, + activist, + spiritual, + artist, + immigrant, + doctor) + { + LoggerFactory = this.LoggerFactory, + ResponseCallback = monitor.ResponseCallback, + }; + + // Start the runtime + await using 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(); + + this.DisplayHistory(monitor.History); + } + + private sealed class AIGroupChatManager(string topic, IChatClient chatClient) : 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(IReadOnlyCollection history, CancellationToken cancellationToken = default) => + this.GetResponseAsync(history, Prompts.Filter(topic), cancellationToken); + + /// + public override ValueTask> SelectNextAgent(IReadOnlyCollection history, GroupChatTeam team, CancellationToken cancellationToken = default) => + this.GetResponseAsync(history, Prompts.Selection(topic, team.FormatList()), cancellationToken); + + /// + public override ValueTask> ShouldRequestUserInput(IReadOnlyCollection history, CancellationToken cancellationToken = default) => + new(new GroupChatManagerResult(false) { Reason = "The AI group chat manager does not request user input." }); + + /// + public override async ValueTask> ShouldTerminate(IReadOnlyCollection 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(IReadOnlyCollection history, string prompt, CancellationToken cancellationToken = default) + { + ChatResponse> response = await chatClient.GetResponseAsync>([.. history, new ChatMessage(ChatRole.System, prompt)], new ChatOptions { ToolMode = ChatToolMode.Auto }, useJsonSchemaResponseFormat: true, cancellationToken); + return response.Result; + } + } +} diff --git a/dotnet/samples/GettingStarted/Orchestration/GroupChatOrchestration_With_HumanInTheLoop.cs b/dotnet/samples/GettingStarted/Orchestration/GroupChatOrchestration_With_HumanInTheLoop.cs new file mode 100644 index 0000000000..dc06280503 --- /dev/null +++ b/dotnet/samples/GettingStarted/Orchestration/GroupChatOrchestration_With_HumanInTheLoop.cs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.Orchestration; +using Microsoft.Agents.Orchestration.GroupChat; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; +using Microsoft.SemanticKernel.Agents.Runtime.InProcess; + +namespace Orchestration; + +/// +/// Demonstrates how to use the with human in the loop +/// +public class GroupChatOrchestration_With_HumanInTheLoop(ITestOutputHelper output) : OrchestrationSample(output) +{ + [Fact] + public async Task RunOrchestrationAsync() + { + // Define the agents + ChatClientAgent 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. + """); + ChatClientAgent 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. + """); + + // Create a monitor to capturing agent responses (via ResponseCallback) + // to display at the end of this sample. (optional) + // NOTE: Create your own callback to capture responses in your application or service. + OrchestrationMonitor monitor = new(); + + // Define the orchestration + GroupChatOrchestration orchestration = + new( + new CustomRoundRobinGroupChatManager() + { + MaximumInvocationCount = 5, + InteractiveCallback = () => + { + ChatMessage input = new(ChatRole.User, "I like it"); + monitor.History.Add(input); + Console.WriteLine($"\n# INPUT: {input.Text}\n"); + return new ValueTask(input); + } + }, + writer, + editor) + { + LoggerFactory = this.LoggerFactory, + ResponseCallback = monitor.ResponseCallback, + }; + + // Start the runtime + await using 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(); + + this.DisplayHistory(monitor.History); + } + + /// + /// 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(IReadOnlyCollection history, CancellationToken cancellationToken = default) + { + string? lastAgent = history.LastOrDefault()?.AuthorName; + + GroupChatManagerResult result; + + if (lastAgent is null) + { + result = new GroupChatManagerResult(false) { Reason = "No agents have spoken yet." }; + } + + if (lastAgent == "Reviewer") + { + result = new GroupChatManagerResult(true) { Reason = "User input is needed after the reviewer's message." }; + } + else + { + result = new GroupChatManagerResult(false) { Reason = "User input is not needed until the reviewer's message." }; + } + + return new ValueTask>(result); + } + } +} diff --git a/dotnet/samples/GettingStarted/Orchestration/HandoffOrchestration_Intro.cs b/dotnet/samples/GettingStarted/Orchestration/HandoffOrchestration_Intro.cs new file mode 100644 index 0000000000..80b6e34a14 --- /dev/null +++ b/dotnet/samples/GettingStarted/Orchestration/HandoffOrchestration_Intro.cs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.Orchestration; +using Microsoft.Agents.Orchestration.Handoff; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; +using Microsoft.SemanticKernel.Agents.Runtime.InProcess; + +namespace 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 HandoffOrchestration_Intro(ITestOutputHelper output) : OrchestrationSample(output) +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RunOrchestrationAsync(bool streamedResponse) + { + // Define the agents & tools + ChatClientAgent triageAgent = + this.CreateAgent( + instructions: "A customer support agent that triages issues.", + name: "TriageAgent", + description: "Handle customer requests."); + ChatClientAgent statusAgent = + this.CreateAgent( + name: "OrderStatusAgent", + instructions: "Handle order status requests.", + description: "A customer support agent that checks order status.", + functions: AIFunctionFactory.Create(OrderFunctions.CheckOrderStatus)); + ChatClientAgent returnAgent = + this.CreateAgent( + name: "OrderReturnAgent", + instructions: "Handle order return requests.", + description: "A customer support agent that handles order returns.", + functions: AIFunctionFactory.Create(OrderFunctions.ProcessReturn)); + ChatClientAgent refundAgent = + this.CreateAgent( + name: "OrderRefundAgent", + instructions: "Handle order refund requests.", + description: "A customer support agent that handles order refund.", + functions: AIFunctionFactory.Create(OrderFunctions.ProcessRefund)); + + // Create a monitor to capturing agent responses (via ResponseCallback) + // to display at the end of this sample. (optional) + // NOTE: Create your own callback to capture responses in your application or service. + OrchestrationMonitor monitor = new(); + // Define user responses for InteractiveCallback (since sample is not interactive) + Queue responses = new(); + 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"); + // Define the orchestration + 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 text = responses.Dequeue(); + ChatMessage input = new(ChatRole.User, text); + monitor.History.Add(input); + Console.WriteLine($"\n# INPUT: {input.Text}\n"); + return new(input); + }, + LoggerFactory = this.LoggerFactory, + ResponseCallback = monitor.ResponseCallback, + StreamingResponseCallback = streamedResponse ? monitor.StreamingResultCallback : null, + }; + + // Start the runtime + await using InProcessRuntime runtime = new(); + await runtime.StartAsync(); + + // Run the orchestration + 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(); + + this.DisplayHistory(monitor.History); + } + + private static class OrderFunctions + { + public static string CheckOrderStatus(string orderId) => $"Order {orderId} is shipped and will arrive in 2-3 days."; + public static string ProcessReturn(string orderId, string reason) => $"Return for order {orderId} has been processed successfully."; + public static string ProcessRefund(string orderId, string reason) => $"Refund for order {orderId} has been processed successfully."; + } +} diff --git a/dotnet/samples/GettingStarted/Orchestration/HandoffOrchestration_With_StructuredInput.cs b/dotnet/samples/GettingStarted/Orchestration/HandoffOrchestration_With_StructuredInput.cs new file mode 100644 index 0000000000..ea57a8ea79 --- /dev/null +++ b/dotnet/samples/GettingStarted/Orchestration/HandoffOrchestration_With_StructuredInput.cs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; +using Microsoft.Agents.Orchestration; +using Microsoft.Agents.Orchestration.Handoff; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; +using Microsoft.SemanticKernel.Agents.Runtime.InProcess; + +namespace Orchestration; + +/// +/// Demonstrates how to use the . +/// +public class HandoffOrchestration_With_StructuredInput(ITestOutputHelper output) : OrchestrationSample(output) +{ + [Fact] + public async Task RunOrchestrationAsync() + { + // Initialize plugin + GithubPlugin githubPlugin = new(); + AIFunction githubAddLabelFunction = AIFunctionFactory.Create(githubPlugin.AddLabels); + + // Define the agents + ChatClientAgent triageAgent = + this.CreateAgent( + instructions: "Given a GitHub issue, triage it.", + name: "TriageAgent", + description: "An agent that triages GitHub issues"); + ChatClientAgent pythonAgent = + this.CreateAgent( + instructions: "You are an agent that handles Python related GitHub issues.", + name: "PythonAgent", + description: "An agent that handles Python related issues", + functions: githubAddLabelFunction); + ChatClientAgent dotnetAgent = + this.CreateAgent( + instructions: "You are an agent that handles .NET related GitHub issues.", + name: "DotNetAgent", + description: "An agent that handles .NET related issues", + functions: githubAddLabelFunction); + + // Create a monitor to capturing agent responses (via ResponseCallback) + // to display at the end of this sample. (optional) + // NOTE: Create your own callback to capture responses in your application or service. + OrchestrationMonitor monitor = new(); + + // Define the orchestration + HandoffOrchestration orchestration = + new(OrchestrationHandoffs + .StartWith(triageAgent) + .Add(triageAgent, dotnetAgent, pythonAgent), + triageAgent, + pythonAgent, + dotnetAgent) + { + LoggerFactory = this.LoggerFactory, + ResponseCallback = monitor.ResponseCallback, + }; + + 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 + await using 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; } = []; + + public void AddLabels(string issueId, params string[] labels) + { + this.Labels[issueId] = labels; + } + } +} diff --git a/dotnet/samples/GettingStarted/Orchestration/SequentialOrchestration_Intro.cs b/dotnet/samples/GettingStarted/Orchestration/SequentialOrchestration_Intro.cs new file mode 100644 index 0000000000..b220b21d20 --- /dev/null +++ b/dotnet/samples/GettingStarted/Orchestration/SequentialOrchestration_Intro.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.Orchestration; +using Microsoft.Agents.Orchestration.Sequential; +using Microsoft.Extensions.AI.Agents; +using Microsoft.SemanticKernel.Agents.Runtime.InProcess; + +namespace 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 SequentialOrchestration_Intro(ITestOutputHelper output) : OrchestrationSample(output) +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RunOrchestrationAsync(bool streamedResponse) + { + // Define the agents + ChatClientAgent 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."); + ChatClientAgent 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."); + ChatClientAgent 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."); + + // Create a monitor to capturing agent responses (via ResponseCallback) + // to display at the end of this sample. (optional) + // NOTE: Create your own callback to capture responses in your application or service. + OrchestrationMonitor monitor = new(); + // Define the orchestration + SequentialOrchestration orchestration = + new(analystAgent, writerAgent, editorAgent) + { + LoggerFactory = this.LoggerFactory, + ResponseCallback = monitor.ResponseCallback, + StreamingResponseCallback = streamedResponse ? monitor.StreamingResultCallback : null, + }; + + // Start the runtime + await using 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(); + + this.DisplayHistory(monitor.History); + } +} diff --git a/dotnet/samples/GettingStarted/Orchestration/SequentialOrchestration_With_Cancellation.cs b/dotnet/samples/GettingStarted/Orchestration/SequentialOrchestration_With_Cancellation.cs new file mode 100644 index 0000000000..d67eaec781 --- /dev/null +++ b/dotnet/samples/GettingStarted/Orchestration/SequentialOrchestration_With_Cancellation.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.Orchestration; +using Microsoft.Agents.Orchestration.Sequential; +using Microsoft.Extensions.AI.Agents; +using Microsoft.SemanticKernel.Agents.Runtime.InProcess; + +namespace Orchestration; + +/// +/// Demonstrates how to use cancel a while its running. +/// +public class SequentialOrchestration_With_Cancellation(ITestOutputHelper output) : OrchestrationSample(output) +{ + [Fact] + public async Task RunOrchestrationAsync() + { + // Define the agents + ChatClientAgent 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 + await using 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 (AggregateException exception) + { + Console.WriteLine($"\n# CANCELLED: {exception.InnerException?.Message}"); + } + + await runtime.RunUntilIdleAsync(); + } +} diff --git a/dotnet/samples/GettingStarted/Resources/Hamlet_full_play_summary.txt b/dotnet/samples/GettingStarted/Resources/Hamlet_full_play_summary.txt new file mode 100644 index 0000000000..9050a46e66 --- /dev/null +++ b/dotnet/samples/GettingStarted/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/Microsoft.Agents.Orchestration/AgentActor.cs b/dotnet/src/Microsoft.Agents.Orchestration/AgentActor.cs new file mode 100644 index 0000000000..12217c85a0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/AgentActor.cs @@ -0,0 +1,170 @@ +// 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.AI; +using Microsoft.Extensions.AI.Agents; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Runtime; + +namespace Microsoft.Agents.Orchestration; + +/// +/// An actor that represents an . +/// +public abstract class AgentActor : OrchestrationActor +{ + /// + /// 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; + this.Thread = this.Agent.GetNewThread(); + } + + /// + /// Gets the associated agent. + /// + protected Agent Agent { get; } + + /// + /// Gets the current conversation thread used during agent communication. + /// + protected AgentThread Thread { get; private set; } + + /// + /// Reset the conversation thread. + /// + protected void ResetThread() + { + this.Thread = this.Agent.GetNewThread(); + } + + /// + /// Invokes the agent for a regular (not streamed) response. + /// + /// The messages to send. + /// The options for running the agent. + /// A cancellation token for the operation. + /// A task that represents the asynchronous operation. + /// + /// Override this method to customize the invocation of the agent. + /// + protected virtual Task InvokeAsync( + IReadOnlyCollection messages, + AgentRunOptions options, + CancellationToken cancellationToken = default) => + this.Agent.RunAsync( + [.. messages], + this.Thread, + options, + cancellationToken); + + /// + /// Invokes the agent for a streamed response. + /// + /// The messages to send. + /// The options for running the agent. + /// A cancellation token for the operation. + /// A task that represents the asynchronous operation. + /// + /// Override this method to customize the invocation of the agent. + /// + protected virtual IAsyncEnumerable InvokeStreamingAsync(IReadOnlyCollection messages, AgentRunOptions options, CancellationToken cancellationToken) => + this.Agent.RunStreamingAsync( + messages, + this.Thread, + options, + cancellationToken); + + /// + /// 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(ChatMessage input, CancellationToken cancellationToken) => + this.InvokeAsync([input], cancellationToken); + + /// + /// Invokes the agent with input messages and respond with both streamed and regular messages. + /// + /// 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(IEnumerable input, CancellationToken cancellationToken) + { + this.Context.Cancellation.ThrowIfCancellationRequested(); + + List? responseMessages = []; + ChatResponse response = new(responseMessages); + + AgentRunOptions options = + new() + { + OnIntermediateMessages = HandleMessage, + }; + + if (this.Context.StreamingResponseCallback == null) + { + // No need to utilize streaming if no callback is provided + await this.InvokeAsync([.. input], options, cancellationToken).ConfigureAwait(false); + } + else + { + IAsyncEnumerable streamedResponses = this.InvokeStreamingAsync([.. input], options, cancellationToken); + ChatResponseUpdate? lastStreamedResponse = null; + await foreach (ChatResponseUpdate streamedResponse in streamedResponses.ConfigureAwait(false)) + { + this.Context.Cancellation.ThrowIfCancellationRequested(); + + await HandleStreamedMessage(lastStreamedResponse, isFinal: false).ConfigureAwait(false); + + lastStreamedResponse = streamedResponse; + } + + await HandleStreamedMessage(lastStreamedResponse, isFinal: true).ConfigureAwait(false); + } + + return response.Messages.Last(); + + async Task HandleMessage(IReadOnlyCollection messages) + { + responseMessages?.AddRange(messages); + + if (this.Context.ResponseCallback is not null) + { + await this.Context.ResponseCallback.Invoke(messages).ConfigureAwait(false); + } + } + + async ValueTask HandleStreamedMessage(ChatResponseUpdate? streamedResponse, bool isFinal) + { + if (this.Context.StreamingResponseCallback != null && streamedResponse != null) + { + await this.Context.StreamingResponseCallback.Invoke(streamedResponse, isFinal).ConfigureAwait(false); + } + } + } + + 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/Microsoft.Agents.Orchestration/AgentOrchestration.RequestActor.cs b/dotnet/src/Microsoft.Agents.Orchestration/AgentOrchestration.RequestActor.cs new file mode 100644 index 0000000000..c8cfe4f6e5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/AgentOrchestration.RequestActor.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Agents.Orchestration.Transforms; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Runtime; +using Microsoft.SemanticKernel.Agents.Runtime.Core; + +namespace Microsoft.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) + { + // 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/Microsoft.Agents.Orchestration/AgentOrchestration.ResultActor.cs b/dotnet/src/Microsoft.Agents.Orchestration/AgentOrchestration.ResultActor.cs new file mode 100644 index 0000000000..3cb5cac6e9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/AgentOrchestration.ResultActor.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Agents.Orchestration.Transforms; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Runtime; +using Microsoft.SemanticKernel.Agents.Runtime.Core; + +namespace Microsoft.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 ChatMessage. + /// A delegate that transforms a ChatMessage 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/Microsoft.Agents.Orchestration/AgentOrchestration.cs b/dotnet/src/Microsoft.Agents.Orchestration/AgentOrchestration.cs new file mode 100644 index 0000000000..7abdce82aa --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/AgentOrchestration.cs @@ -0,0 +1,257 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Orchestration.Extensions; +using Microsoft.Agents.Orchestration.Transforms; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.SemanticKernel.Agents.Runtime; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Orchestration; + +/// +/// Called for every response is produced by any agent. +/// +/// The agent response +public delegate ValueTask OrchestrationResponseCallback(IEnumerable response); + +/// +/// Called to expose the streamed response produced by any agent. +/// +/// The agent response +/// Indicates if streamed content is final chunk of the message. +public delegate ValueTask OrchestrationStreamingCallback(ChatResponseUpdate response, bool isFinal); + +/// +/// 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; } + + /// + /// Optional callback that is invoked for every agent response. + /// + public OrchestrationStreamingCallback? StreamingResponseCallback { 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) + { + Throw.IfNull(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.StreamingResponseCallback, + 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.PublishMessageAsync(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.RegisterOrchestrationAgentAsync( + this.FormatAgentType(context.Topic, "Boot"), + (agentId, runtime) => + { + RequestActor actor = + new(agentId, + runtime, + context, + this.InputTransform, + completion, + StartAsync, + context.LoggerFactory.CreateLogger()); +#if !NETCOREAPP + return new ValueTask(actor); +#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 + AgentType registeredType = + await runtime.RegisterOrchestrationAgentAsync( + agentType, + (agentId, runtime) => + { + ResultActor actor = + new(agentId, + runtime, + context, + resultTransform, + outputTransform, + completion, + context.LoggerFactory.CreateLogger>()); +#if !NETCOREAPP + return new ValueTask(actor); +#else + return ValueTask.FromResult(actor); +#endif + }).ConfigureAwait(false); + + return registeredType; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentActor.cs b/dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentActor.cs new file mode 100644 index 0000000000..6323cd18c3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentActor.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Runtime; +using Microsoft.SemanticKernel.Agents.Runtime.Core; + +namespace Microsoft.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); + + ChatMessage response = await this.InvokeAsync(item.Messages, messageContext.CancellationToken).ConfigureAwait(false); + + this.Logger.LogConcurrentAgentResult(this.Id, response.Text); + + await this.PublishMessageAsync(response.AsResultMessage(), this._handoffActor, messageContext.CancellationToken).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentMessages.cs b/dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentMessages.cs new file mode 100644 index 0000000000..947bfbf7be --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentMessages.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.Orchestration.Concurrent; + +/// +/// Common messages used by the . +/// +internal static class ConcurrentMessages +{ + /// + /// An empty message instance as a default. + /// + public static readonly ChatMessage 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 ChatMessage Message { get; init; } = Empty; + } + + /// + /// Extension method to convert a to a . + /// + public static Result AsResultMessage(this string text, ChatRole? role = null) => new() { Message = new ChatMessage(role ?? ChatRole.Assistant, text) }; + + /// + /// Extension method to convert a to a . + /// + public static Result AsResultMessage(this ChatMessage 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/Microsoft.Agents.Orchestration/Concurrent/ConcurrentOrchestration.String.cs b/dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentOrchestration.String.cs new file mode 100644 index 0000000000..bd9b797141 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentOrchestration.String.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; + +using System.Threading.Tasks; +using Microsoft.Extensions.AI.Agents; + +namespace Microsoft.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.Text)]; +#if !NETCOREAPP + return new ValueTask(result); +#else + return ValueTask.FromResult(result); +#endif + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentOrchestration.cs b/dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentOrchestration.cs new file mode 100644 index 0000000000..4fab83f149 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentOrchestration.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.Orchestration.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Runtime; + +namespace Microsoft.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.RegisterOrchestrationAgentAsync( + resultType, + (agentId, runtime) => + { + ConcurrentResultActor actor = new(agentId, runtime, context, outputType, this.Members.Count, context.LoggerFactory.CreateLogger()); +#if !NETCOREAPP + return new ValueTask(actor); +#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 new ValueTask(actor); +#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/Microsoft.Agents.Orchestration/Concurrent/ConcurrentResultActor.cs b/dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentResultActor.cs new file mode 100644 index 0000000000..0742f4582c --- /dev/null +++ b/dotnet/src/Microsoft.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.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.PublishMessageAsync(this._results.ToArray(), this._orchestrationType, messageContext.CancellationToken).ConfigureAwait(false); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Extensions/RuntimeExtensions.cs b/dotnet/src/Microsoft.Agents.Orchestration/Extensions/RuntimeExtensions.cs new file mode 100644 index 0000000000..d919cf9838 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Extensions/RuntimeExtensions.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.SemanticKernel.Agents.Runtime; +using Microsoft.SemanticKernel.Agents.Runtime.Core; + +namespace Microsoft.Agents.Orchestration.Extensions; + +/// +/// Extension methods for . +/// +public static class RuntimeExtensions +{ + /// + /// Sends a message to the specified agent. + /// + public static async ValueTask PublishMessageAsync(this IAgentRuntime runtime, object message, AgentType agentType, CancellationToken cancellationToken = default) + { + await runtime.PublishMessageAsync(message, new TopicId(agentType), sender: null, messageId: null, cancellationToken).ConfigureAwait(false); + } + + /// + /// Registers an agent factory for the specified agent type and associates it with the runtime. + /// + /// The runtime targeted for registration. + /// The type of agent to register. + /// The factory function for creating the agent. + /// The registered agent type. + public static async ValueTask RegisterOrchestrationAgentAsync(this IAgentRuntime runtime, AgentType agentType, Func> factoryFunc) + { + AgentType registeredType = await runtime.RegisterAgentFactoryAsync(agentType, factoryFunc).ConfigureAwait(false); + + // Subscribe agent to its own unique topic + await runtime.SubscribeAsync(registeredType).ConfigureAwait(false); + + return registeredType; + } + + /// + /// Subscribes the specified agent type to its own dedicated topic. + /// + /// The runtime for managing the subscription. + /// The agent type to subscribe. + public static async Task SubscribeAsync(this IAgentRuntime runtime, string agentType) + { + await runtime.AddSubscriptionAsync(new TypeSubscription(agentType, agentType)).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/Microsoft.Agents.Orchestration/GroupChat/GroupChatAgentActor.cs b/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatAgentActor.cs new file mode 100644 index 0000000000..c64b076093 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatAgentActor.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Runtime; +using Microsoft.SemanticKernel.Agents.Runtime.Core; + +namespace Microsoft.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 new ValueTask(); +#else + return ValueTask.CompletedTask; +#endif + } + + /// + public ValueTask HandleAsync(GroupChatMessages.Reset item, MessageContext messageContext) + { + this.ResetThread(); + +#if !NETCOREAPP + return new ValueTask(); +#else + return ValueTask.CompletedTask; +#endif + } + + /// + public async ValueTask HandleAsync(GroupChatMessages.Speak item, MessageContext messageContext) + { + this.Logger.LogChatAgentInvoke(this.Id); + + ChatMessage response = await this.InvokeAsync(this._cache, messageContext.CancellationToken).ConfigureAwait(false); + + this.Logger.LogChatAgentResult(this.Id, response.Text); + + this._cache.Clear(); + await this.PublishMessageAsync(response.AsGroupMessage(), this.Context.Topic).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatManager.cs b/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatManager.cs new file mode 100644 index 0000000000..d82e961da0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatManager.cs @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.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(IReadOnlyCollection 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(IReadOnlyCollection 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(IReadOnlyCollection 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(IReadOnlyCollection 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 new ValueTask>(result); +#else + return ValueTask.FromResult(result); +#endif + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatManagerActor.cs b/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatManagerActor.cs new file mode 100644 index 0000000000..6ab1927a63 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatManagerActor.cs @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Runtime; +using Microsoft.SemanticKernel.Agents.Runtime.Core; + +namespace Microsoft.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 List _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) + { + ChatMessage input = await this._manager.InteractiveCallback.Invoke().ConfigureAwait(false); + this.Logger.LogChatManagerUserInput(this.Id, input.Text); + 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.PublishMessageAsync(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.PublishMessageAsync(new GroupChatMessages.Speak(), selectionType, messageContext.CancellationToken).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatMessages.cs b/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatMessages.cs new file mode 100644 index 0000000000..f4bc53e9ac --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatMessages.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.Orchestration.GroupChat; + +/// +/// Common messages used for agent chat patterns. +/// +public static class GroupChatMessages +{ + /// + /// An empty message instance as a default. + /// + internal static readonly ChatMessage 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 ChatMessage 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 ChatMessage 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(ChatRole.Assistant, text) }; +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatOrchestration.String.cs b/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatOrchestration.String.cs new file mode 100644 index 0000000000..f1a17142ee --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatOrchestration.String.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI.Agents; + +namespace Microsoft.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/Microsoft.Agents.Orchestration/GroupChat/GroupChatOrchestration.cs b/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatOrchestration.cs new file mode 100644 index 0000000000..63fa0f34c1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatOrchestration.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Agents.Orchestration.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Runtime; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.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) + { + Throw.IfNull(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.PublishMessageAsync(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.RegisterOrchestrationAgentAsync( + this.FormatAgentType(context.Topic, "Manager"), + (agentId, runtime) => + { + GroupChatManagerActor actor = new(agentId, runtime, context, this._manager, team, outputType, context.LoggerFactory.CreateLogger()); +#if !NETCOREAPP + return new ValueTask(actor); +#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.RegisterOrchestrationAgentAsync( + this.FormatAgentType(context.Topic, $"Agent_{agentCount}"), + (agentId, runtime) => + { + GroupChatAgentActor actor = new(agentId, runtime, context, agent, context.LoggerFactory.CreateLogger()); +#if !NETCOREAPP + return new ValueTask(actor); +#else + return ValueTask.FromResult(actor); +#endif + }); + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatTeam.cs b/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatTeam.cs new file mode 100644 index 0000000000..9409c5561a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatTeam.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.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(Environment.NewLine, team.Select(t => $"- {t.Key}: {t.Value.Description}")); +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/RoundRobinGroupChatManager.cs b/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/RoundRobinGroupChatManager.cs new file mode 100644 index 0000000000..e0f11475ec --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/RoundRobinGroupChatManager.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.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(IReadOnlyCollection history, CancellationToken cancellationToken = default) + { + GroupChatManagerResult result = new(history.LastOrDefault()?.Text ?? string.Empty) { Reason = "Default result filter provides the final chat message." }; +#if !NETCOREAPP + return new ValueTask>(result); +#else + return ValueTask.FromResult(result); +#endif + } + + /// + public override ValueTask> SelectNextAgent(IReadOnlyCollection 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 new ValueTask>(result); +#else + return ValueTask.FromResult(result); +#endif + } + + /// + public override ValueTask> ShouldRequestUserInput(IReadOnlyCollection history, CancellationToken cancellationToken = default) + { + GroupChatManagerResult result = new(false) { Reason = "The default round-robin group chat manager does not request user input." }; +#if !NETCOREAPP + return new ValueTask>(result); +#else + return ValueTask.FromResult(result); +#endif + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Handoff/HandoffActor.cs b/dotnet/src/Microsoft.Agents.Orchestration/Handoff/HandoffActor.cs new file mode 100644 index 0000000000..fb79c0eaec --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Handoff/HandoffActor.cs @@ -0,0 +1,211 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Runtime; +using Microsoft.SemanticKernel.Agents.Runtime.Core; + +namespace Microsoft.Agents.Orchestration.Handoff; + +/// +/// An actor used with the . +/// +internal sealed class HandoffActor : + AgentActor, + IHandle, + IHandle, + IHandle +{ + private readonly ChatClientAgent _chatAgent; + private readonly HandoffLookup _handoffs; + private readonly AgentType _resultHandoff; + private readonly List _cache; + private readonly ChatOptions _options; + + 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, ChatClientAgent 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._chatAgent = agent; + this._handoffs = handoffs; + this._resultHandoff = resultHandoff; + this._options = + new ChatOptions + { + Tools = [.. this.CreateHandoffFunctions()], + ToolMode = ChatToolMode.Auto + }; + } + + /// + protected override Task InvokeAsync( + IReadOnlyCollection messages, + AgentRunOptions options, + CancellationToken cancellationToken = default) => + this._chatAgent.RunAsync( + [.. messages], + this.Thread, + options, + this._options, + cancellationToken); + + /// + protected override IAsyncEnumerable InvokeStreamingAsync(IReadOnlyCollection messages, AgentRunOptions options, CancellationToken cancellationToken) => + this._chatAgent.RunStreamingAsync( + messages, + this.Thread, + options, + this._options, + cancellationToken); + + /// + /// Gets or sets the callback to be invoked for interactive input. + /// + public OrchestrationInteractiveCallback? InteractiveCallback { get; init; } + + /// + public ValueTask HandleAsync(HandoffMessages.InputTask item, MessageContext messageContext) + { + this._taskSummary = null; + this._cache.AddRange(item.Messages); + +#if !NETCOREAPP + return new ValueTask(); +#else + return ValueTask.CompletedTask; +#endif + } + + /// + public ValueTask HandleAsync(HandoffMessages.Response item, MessageContext messageContext) + { + this._cache.Add(item.Message); + +#if !NETCOREAPP + return new ValueTask(); +#else + return ValueTask.CompletedTask; +#endif + } + + /// + public async ValueTask HandleAsync(HandoffMessages.Request item, MessageContext messageContext) + { + try + { + this.Logger.LogHandoffAgentInvoke(this.Id); + + while (this._taskSummary == null) + { + ChatMessage response; + try + { + response = await this.InvokeAsync(this._cache, messageContext.CancellationToken).ConfigureAwait(false); + } + catch (Exception exception) + { + this.Logger.LogError(exception, "Failure"); + throw; + } + + this._cache.Clear(); + + this.Logger.LogHandoffAgentResult(this.Id, response.Text); + + // 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 == ChatRole.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.PublishMessageAsync(new HandoffMessages.Request(), handoffType, messageContext.CancellationToken).ConfigureAwait(false); + + this._handoffAgent = null; + break; + } + + if (this.InteractiveCallback != null && this._taskSummary == null) + { + ChatMessage input = await this.InteractiveCallback().ConfigureAwait(false); + await this.PublishMessageAsync(new HandoffMessages.Response { Message = input }, this.Context.Topic, messageId: null, messageContext.CancellationToken).ConfigureAwait(false); + this._cache.Add(input); + continue; + } + + await this.EndAsync(response.Text ?? "No handoff or human response function requested. Ending task.", messageContext.CancellationToken).ConfigureAwait(false); + } + } + catch (Exception exception) + { + this.Logger.LogError(exception, "Failure"); + throw; + } + } + + private IEnumerable CreateHandoffFunctions() + { + yield return AIFunctionFactory.Create( + this.EndAsync, + name: "end_task", + description: "Complete the task with a summary when no further requests are given."); + + foreach (KeyValuePair handoff in this._handoffs) + { + AIFunction handoffFunction = + AIFunctionFactory.Create( + () => this.Handoff(handoff.Key), + name: $"transfer_to_{handoff.Key}", + description: handoff.Value.Description); + + yield return handoffFunction; + } + } + + private void Handoff(string agentName) + { + this.Logger.LogHandoffFunctionCall(this.Id, agentName); + this._handoffAgent = agentName; + + FunctionInvokingChatClient.CurrentContext!.Terminate = true; + } + + private async ValueTask EndAsync(string summary, CancellationToken cancellationToken) + { + this.Logger.LogHandoffSummary(this.Id, summary); + this._taskSummary = summary; + await this.PublishMessageAsync(new HandoffMessages.Result { Message = new ChatMessage(ChatRole.Assistant, summary) }, this._resultHandoff, cancellationToken).ConfigureAwait(false); + + if (FunctionInvokingChatClient.CurrentContext is not null) + { + FunctionInvokingChatClient.CurrentContext.Terminate = true; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Handoff/HandoffMessages.cs b/dotnet/src/Microsoft.Agents.Orchestration/Handoff/HandoffMessages.cs new file mode 100644 index 0000000000..a4764871c1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Handoff/HandoffMessages.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Extensions.AI; + +namespace Microsoft.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 ChatMessage 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 ChatMessage 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 ChatMessage 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 ChatMessage message) => new() { Message = message }; +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Handoff/HandoffOrchestration.String.cs b/dotnet/src/Microsoft.Agents.Orchestration/Handoff/HandoffOrchestration.String.cs new file mode 100644 index 0000000000..bd698778f9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Handoff/HandoffOrchestration.String.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI.Agents; + +namespace Microsoft.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/Microsoft.Agents.Orchestration/Handoff/HandoffOrchestration.cs b/dotnet/src/Microsoft.Agents.Orchestration/Handoff/HandoffOrchestration.cs new file mode 100644 index 0000000000..2de4af2e9d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Handoff/HandoffOrchestration.cs @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.Orchestration.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Runtime; + +namespace Microsoft.Agents.Orchestration.Handoff; + +/// +/// An orchestration that provides the input message to the first agent +/// and sequentially 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.PublishMessageAsync(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.RegisterOrchestrationAgentAsync( + this.GetAgentType(context.Topic, index), + (agentId, runtime) => + { + HandoffActor actor = + new(agentId, runtime, context, (ChatClientAgent)agent, map, outputType, context.LoggerFactory.CreateLogger()) + { + InteractiveCallback = this.InteractiveCallback + }; +#if !NETCOREAPP + return new ValueTask(actor); +#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/Microsoft.Agents.Orchestration/Handoff/Handoffs.cs b/dotnet/src/Microsoft.Agents.Orchestration/Handoff/Handoffs.cs new file mode 100644 index 0000000000..3e1b1983c0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Handoff/Handoffs.cs @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Extensions.AI.Agents; +using Microsoft.SemanticKernel.Agents.Runtime; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.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) + { + Throw.IfNullOrWhitespace(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/Microsoft.Agents.Orchestration/Logging/AgentOrchestrationLogMessages.cs b/dotnet/src/Microsoft.Agents.Orchestration/Logging/AgentOrchestrationLogMessages.cs new file mode 100644 index 0000000000..a62764c38f --- /dev/null +++ b/dotnet/src/Microsoft.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.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/Microsoft.Agents.Orchestration/Logging/ConcurrentOrchestrationLogMessages.cs b/dotnet/src/Microsoft.Agents.Orchestration/Logging/ConcurrentOrchestrationLogMessages.cs new file mode 100644 index 0000000000..0d36ddc9b3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Logging/ConcurrentOrchestrationLogMessages.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Agents.Orchestration.Concurrent; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Runtime; + +namespace Microsoft.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/Microsoft.Agents.Orchestration/Logging/GroupChatOrchestrationLogMessages.cs b/dotnet/src/Microsoft.Agents.Orchestration/Logging/GroupChatOrchestrationLogMessages.cs new file mode 100644 index 0000000000..1dbabb7f8e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Logging/GroupChatOrchestrationLogMessages.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Agents.Orchestration.GroupChat; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Runtime; + +namespace Microsoft.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/Microsoft.Agents.Orchestration/Logging/HandoffOrchestrationLogMessages.cs b/dotnet/src/Microsoft.Agents.Orchestration/Logging/HandoffOrchestrationLogMessages.cs new file mode 100644 index 0000000000..b29938e0e7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Logging/HandoffOrchestrationLogMessages.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Agents.Orchestration.Handoff; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Runtime; + +namespace Microsoft.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/Microsoft.Agents.Orchestration/Logging/OrchestrationResultLogMessages.cs b/dotnet/src/Microsoft.Agents.Orchestration/Logging/OrchestrationResultLogMessages.cs new file mode 100644 index 0000000000..f3edf26b60 --- /dev/null +++ b/dotnet/src/Microsoft.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.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/Microsoft.Agents.Orchestration/Logging/SequentialOrchestrationLogMessages.cs b/dotnet/src/Microsoft.Agents.Orchestration/Logging/SequentialOrchestrationLogMessages.cs new file mode 100644 index 0000000000..a678fabcd1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Logging/SequentialOrchestrationLogMessages.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Agents.Orchestration.Sequential; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Runtime; + +namespace Microsoft.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/Microsoft.Agents.Orchestration/Marker.cs b/dotnet/src/Microsoft.Agents.Orchestration/Marker.cs new file mode 100644 index 0000000000..061b173c01 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Marker.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft. All rights reserved. + +#if !NET5_0_OR_GREATER +using System.ComponentModel; + +namespace System.Runtime.CompilerServices; + +[EditorBrowsable(EditorBrowsableState.Never)] +internal static class IsExternalInit { } +#endif diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Microsoft.Agents.Orchestration.csproj b/dotnet/src/Microsoft.Agents.Orchestration/Microsoft.Agents.Orchestration.csproj new file mode 100644 index 0000000000..21a2926857 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Microsoft.Agents.Orchestration.csproj @@ -0,0 +1,37 @@ + + + + $(ProjectsTargetFrameworks) + $(ProjectsDebugTargetFrameworks) + Microsoft.Agents.Orchestration + alpha + false + + + + true + + + + + + + Microsoft Agent Orchestration Framework + Contains the Microsoft Agent Orchestration Framework. + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.Orchestration/OrchestrationActor.cs b/dotnet/src/Microsoft.Agents.Orchestration/OrchestrationActor.cs new file mode 100644 index 0000000000..34a8937de3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/OrchestrationActor.cs @@ -0,0 +1,44 @@ +// 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.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 PublishMessageAsync( + object message, + AgentType agentType, + CancellationToken cancellationToken = default) + { + await base.PublishMessageAsync(message, new TopicId(agentType), messageId: null, cancellationToken).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/OrchestrationContext.cs b/dotnet/src/Microsoft.Agents.Orchestration/OrchestrationContext.cs new file mode 100644 index 0000000000..5daf6e8d68 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/OrchestrationContext.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Runtime; + +namespace Microsoft.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, + OrchestrationStreamingCallback? streamingCallback, + ILoggerFactory loggerFactory, + CancellationToken cancellation) + { + this.Orchestration = orchestration; + this.Topic = topic; + this.ResponseCallback = responseCallback; + this.StreamingResponseCallback = streamingCallback; + 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; } + + /// + /// Optional callback that is invoked for every agent response. + /// + public OrchestrationStreamingCallback? StreamingResponseCallback { get; } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/OrchestrationResult.cs b/dotnet/src/Microsoft.Agents.Orchestration/OrchestrationResult.cs new file mode 100644 index 0000000000..cac361741a --- /dev/null +++ b/dotnet/src/Microsoft.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.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/Microsoft.Agents.Orchestration/Sequential/SequentialActor.cs b/dotnet/src/Microsoft.Agents.Orchestration/Sequential/SequentialActor.cs new file mode 100644 index 0000000000..b07556eb11 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Sequential/SequentialActor.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Runtime; +using Microsoft.SemanticKernel.Agents.Runtime.Core; + +namespace Microsoft.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); + + ChatMessage response = await this.InvokeAsync(input, messageContext.CancellationToken).ConfigureAwait(false); + + this.Logger.LogSequentialAgentResult(this.Id, response.Text); + + await this.PublishMessageAsync(response.AsResponseMessage(), this._nextAgent).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Sequential/SequentialMessages.cs b/dotnet/src/Microsoft.Agents.Orchestration/Sequential/SequentialMessages.cs new file mode 100644 index 0000000000..ec9fae3a0c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Sequential/SequentialMessages.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Extensions.AI; + +namespace Microsoft.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 ChatMessage 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 ChatMessage 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 ChatMessage 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 ChatMessage message) => new() { Message = message }; +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Sequential/SequentialOrchestration.String.cs b/dotnet/src/Microsoft.Agents.Orchestration/Sequential/SequentialOrchestration.String.cs new file mode 100644 index 0000000000..a44a2c372f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Sequential/SequentialOrchestration.String.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI.Agents; + +namespace Microsoft.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/Microsoft.Agents.Orchestration/Sequential/SequentialOrchestration.cs b/dotnet/src/Microsoft.Agents.Orchestration/Sequential/SequentialOrchestration.cs new file mode 100644 index 0000000000..07bd5a086a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Sequential/SequentialOrchestration.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Agents.Orchestration.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Agents.Runtime; + +namespace Microsoft.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.PublishMessageAsync(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.RegisterOrchestrationAgentAsync( + this.GetAgentType(context.Topic, index), + (agentId, runtime) => + { + SequentialActor actor = new(agentId, runtime, context, agent, nextAgent, context.LoggerFactory.CreateLogger()); + +#if !NETCOREAPP + return new ValueTask(actor); +#else + return ValueTask.FromResult(actor); +#endif + }); + } + + private AgentType GetAgentType(TopicId topic, int index) => this.FormatAgentType(topic, $"Agent_{index + 1}"); +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Transforms/DefaultTransforms.cs b/dotnet/src/Microsoft.Agents.Orchestration/Transforms/DefaultTransforms.cs new file mode 100644 index 0000000000..e5b080188f --- /dev/null +++ b/dotnet/src/Microsoft.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.Extensions.AI; + +namespace Microsoft.Agents.Orchestration.Transforms; + +internal static class DefaultTransforms +{ + public static ValueTask> FromInput(TInput input, CancellationToken cancellationToken = default) + { +#if !NETCOREAPP + return new ValueTask>(TransformInput()); +#else + return ValueTask.FromResult(TransformInput()); +#endif + + IEnumerable TransformInput() => + input switch + { + IEnumerable messages => messages, + ChatMessage message => [message], + string text => [new ChatMessage(ChatRole.User, text)], + _ => [new ChatMessage(ChatRole.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].Text); + } + catch (JsonException) + { + return default; + } + } + + TOutput? GetDefaultOutput() + { + object? output = null; + if (typeof(TOutput).IsAssignableFrom(result.GetType())) + { + output = (object)result; + } + else if (isSingleResult && typeof(ChatMessage).IsAssignableFrom(typeof(TOutput))) + { + output = (object)result[0]; + } + else if (isSingleResult && typeof(string) == typeof(TOutput)) + { + output = result[0].Text ?? string.Empty; + } + + return (TOutput?)output; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Transforms/OrchestrationTransforms.cs b/dotnet/src/Microsoft.Agents.Orchestration/Transforms/OrchestrationTransforms.cs new file mode 100644 index 0000000000..6a1dfe9f45 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Transforms/OrchestrationTransforms.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.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/Microsoft.Agents.Orchestration/Transforms/StructuredOutputTransform.cs b/dotnet/src/Microsoft.Agents.Orchestration/Transforms/StructuredOutputTransform.cs new file mode 100644 index 0000000000..cda2788285 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Transforms/StructuredOutputTransform.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.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 IChatClient _client; + private readonly ChatOptions? _options; + + /// + /// 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(IChatClient client, ChatOptions? chatOptions = null) + { + Throw.IfNull(client, nameof(client)); + + this._client = client; + this._options = chatOptions; + } + + /// + /// 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) + { + IEnumerable input = + [ + new ChatMessage(ChatRole.System, this.Instructions), + .. messages, + ]; + ChatResponse response = await this._client.GetResponseAsync(input, this._options, useJsonSchemaResponseFormat: true, cancellationToken).ConfigureAwait(false); + return response.Result; + } +} diff --git a/dotnet/src/Shared/Samples/BaseSample.cs b/dotnet/src/Shared/Samples/BaseSample.cs index ed3986137a..ed3c80a014 100644 --- a/dotnet/src/Shared/Samples/BaseSample.cs +++ b/dotnet/src/Shared/Samples/BaseSample.cs @@ -80,7 +80,7 @@ public abstract class BaseSample : TextWriter /// The text of the message to be sent. Cannot be null or empty. protected void WriteUserMessage(string message) { - this.WriteResponseOutput(new ChatResponse(new ChatMessage(ChatRole.User, message)), printUsage: false); + this.WriteMessageOutput(new ChatMessage(ChatRole.User, message)); } /// @@ -101,8 +101,28 @@ public abstract class BaseSample : TextWriter } var message = chatResponse.Messages.Last(); + this.WriteMessageOutput(message); + + WriteUsage(); + + void WriteUsage() + { + if (!(printUsage ?? true) || chatResponse.Usage is null) { return; } + + UsageDetails usageDetails = chatResponse.Usage; + + Console.WriteLine($" [Usage] Tokens: {usageDetails.TotalTokenCount}, Input: {usageDetails.InputTokenCount}, Output: {usageDetails.OutputTokenCount}"); + } + } + + /// + /// Writes the given chat message to the console. + /// + /// The specified message + protected void WriteMessageOutput(ChatMessage message) + { string authorExpression = message.Role == ChatRole.User ? string.Empty : FormatAuthor(); - string contentExpression = string.IsNullOrWhiteSpace(chatResponse.Text) ? string.Empty : chatResponse.Text; + string contentExpression = message.Text.Trim(); bool isCode = false; //message.AdditionalProperties?.ContainsKey(OpenAIAssistantAgent.CodeInterpreterMetadataKey) ?? false; string codeMarker = isCode ? "\n [CODE]\n" : " "; Console.WriteLine($"\n# {message.Role}{authorExpression}:{codeMarker}{contentExpression}"); @@ -124,16 +144,7 @@ public abstract class BaseSample : TextWriter } } - WriteUsage(chatResponse.Usage); - string FormatAuthor() => message.AuthorName is not null ? $" - {message.AuthorName ?? " * "}" : string.Empty; - - void WriteUsage(UsageDetails? usageDetails) - { - if (!(printUsage ?? true) || usageDetails is null) { return; } - - Console.WriteLine($" [Usage] Tokens: {usageDetails.TotalTokenCount}, Input: {usageDetails.InputTokenCount}, Output: {usageDetails.OutputTokenCount}"); - } } /// diff --git a/dotnet/src/Shared/Samples/OrchestrationSample.cs b/dotnet/src/Shared/Samples/OrchestrationSample.cs new file mode 100644 index 0000000000..3f9fa69ebc --- /dev/null +++ b/dotnet/src/Shared/Samples/OrchestrationSample.cs @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; +using Microsoft.Shared.Samples; +using OpenAIClient = OpenAI.OpenAIClient; + +namespace Microsoft.Shared.SampleUtilities; + +/// +/// Provides a base class for orchestration samples that demonstrates agent orchestration scenarios. +/// Inherits from and provides utility methods for creating agents, chat clients, +/// and writing responses to the console or test output. +/// +public abstract class OrchestrationSample : BaseSample +{ + /// + /// This constant defines the timeout duration for result retrieval, measured in seconds. + /// + protected const int ResultTimeoutInSeconds = 30; + + /// + /// Creates a new instance using the specified instructions, description, name, and functions. + /// + /// The instructions to provide to the agent. + /// An optional description for the agent. + /// An optional name for the agent. + /// A set of instances to be used as tools by the agent. + /// A new instance configured with the provided parameters. + protected ChatClientAgent CreateAgent(string instructions, string? description = null, string? name = null, params AIFunction[] functions) + { + // Get the chat client to use for the agent. + using IChatClient chatClient = CreateChatClient(); + + ChatClientAgentOptions options = + new() + { + Name = name, + Description = description, + Instructions = instructions, + ChatOptions = new() { Tools = functions, ToolMode = ChatToolMode.Auto } + }; + + return new ChatClientAgent(chatClient, options); + } + + /// + /// Creates and configures a new instance using the OpenAI client and test configuration. + /// + /// A configured instance ready for use with agents. + protected IChatClient CreateChatClient() + { + return new OpenAIClient(TestConfiguration.OpenAI.ApiKey) + .GetChatClient(TestConfiguration.OpenAI.ChatModelId) + .AsIChatClient() + .AsBuilder() + .UseFunctionInvocation() + .Build(); + } + + /// + /// Display the provided history. + /// + /// The history to display + protected void DisplayHistory(IEnumerable history) + { + Console.WriteLine("\n\nORCHESTRATION HISTORY"); + foreach (ChatMessage message in history) + { + this.WriteMessageOutput(message); + } + } + + /// + /// Writes the provided chat response messages to the console or test output, including role and author information. + /// + /// An enumerable of objects to write. + protected static void WriteResponse(IEnumerable response) + { + foreach (ChatMessage message in response) + { + if (!string.IsNullOrEmpty(message.Text)) + { + System.Console.WriteLine($"\n# RESPONSE {message.Role}{(message.AuthorName is not null ? $" - {message.AuthorName}" : string.Empty)}: {message}"); + } + } + } + + /// + /// Writes the streamed chat response updates to the console or test output, including role and author information. + /// + /// An enumerable of objects representing streamed responses. + protected static void WriteStreamedResponse(IEnumerable streamedResponses) + { + string? authorName = null; + ChatRole? authorRole = null; + StringBuilder builder = new(); + foreach (ChatResponseUpdate response in streamedResponses) + { + authorName ??= response.AuthorName; + authorRole ??= response.Role; + + if (!string.IsNullOrEmpty(response.Text)) + { + builder.Append($"({JsonSerializer.Serialize(response.Text)})"); + } + } + + if (builder.Length > 0) + { + System.Console.WriteLine($"\n# STREAMED {authorRole ?? ChatRole.Assistant}{(authorName is not null ? $" - {authorName}" : string.Empty)}: {builder}\n"); + } + } + + /// + /// Provides monitoring and callback functionality for orchestration scenarios, including tracking streamed responses and message history. + /// + protected sealed class OrchestrationMonitor + { + /// + /// Gets the list of streamed response updates received so far. + /// + public List StreamedResponses { get; } = []; + + /// + /// Gets the list of chat messages representing the conversation history. + /// + public List History { get; } = []; + + /// + /// Callback to handle a batch of chat messages, adding them to history and writing them to output. + /// + /// The collection of objects to process. + /// A representing the asynchronous operation. + public ValueTask ResponseCallback(IEnumerable response) + { + this.History.AddRange(response); + WriteResponse(response); + return new ValueTask(); + } + + /// + /// Callback to handle a streamed chat response update, adding it to the list and writing output if final. + /// + /// The to process. + /// Indicates whether this is the final update in the stream. + /// A representing the asynchronous operation. + public ValueTask StreamingResultCallback(ChatResponseUpdate streamedResponse, bool isFinal) + { + this.StreamedResponses.Add(streamedResponse); + + if (isFinal) + { + WriteStreamedResponse(this.StreamedResponses); + this.StreamedResponses.Clear(); + } + + return new ValueTask(); + } + } + + /// + /// Initializes a new instance of the class, setting up logging, configuration, and + /// optionally redirecting output to the test output. + /// + /// This constructor initializes logging using an and sets up + /// configuration from multiple sources, including a JSON file, environment variables, and user secrets. + /// If is , calls to + /// will be redirected to the test output provided by . + /// + /// The instance used to write test output. + /// + /// A value indicating whether output should be redirected to the test output. to redirect; otherwise, . + /// + protected OrchestrationSample(ITestOutputHelper output, bool redirectSystemConsoleOutput = true) + : base(output, redirectSystemConsoleOutput) + { + } +} diff --git a/dotnet/src/Shared/Samples/Resources.cs b/dotnet/src/Shared/Samples/Resources.cs new file mode 100644 index 0000000000..bebf790672 --- /dev/null +++ b/dotnet/src/Shared/Samples/Resources.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Shared.Samples; + +/// +/// Resource helper to load resources. +/// +internal static class Resources +{ + private const string ResourceFolder = "Resources"; + + public static string Read(string fileName) => File.ReadAllText($"{ResourceFolder}/{fileName}"); +} diff --git a/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/ChatGroupExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/ChatGroupExtensionsTests.cs new file mode 100644 index 0000000000..401875b185 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/ChatGroupExtensionsTests.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Agents.Orchestration.GroupChat; + +namespace Microsoft.Agents.Orchestration.UnitTest; + +public class ChatGroupExtensionsTests +{ + [Fact] + public void FormatNamesWithMultipleAgentsReturnsCommaSeparatedList() + { + // 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 FormatNamesWithSingleAgentReturnsSingleName() + { + // Arrange + GroupChatTeam group = new() + { + { "AgentOne", ("agent1", "First agent description") }, + }; + + // Act + string result = group.FormatNames(); + + // Assert + Assert.Equal("AgentOne", result); + } + + [Fact] + public void FormatNamesWithEmptyGroupReturnsEmptyString() + { + // Arrange + GroupChatTeam group = []; + + // Act + string result = group.FormatNames(); + + // Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void FormatListWithMultipleAgentsReturnsMarkdownList() + { + // 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 + string expected = $"- AgentOne: First agent description{Environment.NewLine}- AgentTwo: Second agent description{Environment.NewLine}- AgentThree: Third agent description"; + Assert.Equal(expected, result); + } + + [Fact] + public void FormatListWithEmptyGroupReturnsEmptyString() + { + // Arrange + GroupChatTeam group = []; + + // Act & Assert + Assert.Equal(string.Empty, group.FormatNames()); + Assert.Equal(string.Empty, group.FormatList()); + } +} diff --git a/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/ConcurrentOrchestrationTests.cs b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/ConcurrentOrchestrationTests.cs new file mode 100644 index 0000000000..54e4f72ef9 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/ConcurrentOrchestrationTests.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Agents.Orchestration.Concurrent; +using Microsoft.Extensions.AI.Agents; +using Microsoft.SemanticKernel.Agents.Runtime.InProcess; + +namespace Microsoft.Agents.Orchestration.UnitTest; + +/// +/// Tests for the class. +/// +public class ConcurrentOrchestrationTests +{ + [Fact] + public async Task ConcurrentOrchestrationWithSingleAgentAsync() + { + // Arrange + await using InProcessRuntime runtime = new(); + MockAgent mockAgent1 = MockAgent.CreateWithResponse(1, "xyz"); + + // Act: Create and execute the orchestration + string[] response = await ExecuteOrchestrationAsync(runtime, mockAgent1); + + // Assert + Assert.Equal(1, mockAgent1.InvokeCount); + Assert.Contains("xyz", response); + } + + [Fact] + public async Task ConcurrentOrchestrationWithMultipleAgentsAsync() + { + // Arrange + await using InProcessRuntime runtime = new(); + + MockAgent mockAgent1 = MockAgent.CreateWithResponse(1, "abc"); + MockAgent mockAgent2 = MockAgent.CreateWithResponse(2, "xyz"); + MockAgent mockAgent3 = MockAgent.CreateWithResponse(3, "lmn"); + + // Act: Create and execute the orchestration + string[] response = await ExecuteOrchestrationAsync(runtime, mockAgent1, mockAgent2, mockAgent3); + + // Assert + Assert.Equal(1, mockAgent1.InvokeCount); + Assert.Equal(1, mockAgent2.InvokeCount); + Assert.Equal(1, mockAgent3.InvokeCount); + Assert.Contains("lmn", response); + Assert.Contains("xyz", response); + Assert.Contains("abc", response); + } + + 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; + } +} diff --git a/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/DefaultTransformsTests.cs b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/DefaultTransformsTests.cs new file mode 100644 index 0000000000..0e142f3965 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/DefaultTransformsTests.cs @@ -0,0 +1,201 @@ +// 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.Agents.Orchestration.Transforms; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.Orchestration.UnitTest; + +public class DefaultTransformsTests +{ + [Fact] + public async Task FromInputAsyncWithEnumerableOfChatMessageReturnsInputAsync() + { + // Arrange + IEnumerable input = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi there") + ]; + + // Act + IEnumerable result = await DefaultTransforms.FromInput(input); + + // Assert + Assert.Equal(input, result); + } + + [Fact] + public async Task FromInputAsyncWithChatMessageReturnsInputAsListAsync() + { + // Arrange + ChatMessage input = new(ChatRole.User, "Hello"); + + // Act + IEnumerable result = await DefaultTransforms.FromInput(input); + + // Assert + Assert.Single(result); + Assert.Equal(input, result.First()); + } + + [Fact] + public async Task FromInputAsyncWithStringInputReturnsUserChatMessageAsync() + { + // Arrange + string input = "Hello, world!"; + + // Act + IEnumerable result = await DefaultTransforms.FromInput(input); + + // Assert + Assert.Single(result); + ChatMessage message = result.First(); + Assert.Equal(ChatRole.User, message.Role); + Assert.Equal(input, message.Text); + } + + [Fact] + public async Task FromInputAsyncWithObjectInputSerializesAsJsonAsync() + { + // Arrange + TestObject input = new() { Id = 1, Name = "Test" }; + + // Act + IEnumerable result = await DefaultTransforms.FromInput(input); + + // Assert + Assert.Single(result); + ChatMessage message = result.First(); + Assert.Equal(ChatRole.User, message.Role); + + string expectedJson = JsonSerializer.Serialize(input); + Assert.Equal(expectedJson, message.Text); + } + + [Fact] + public async Task ToOutputAsyncWithOutputTypeMatchingInputListReturnsSameListAsync() + { + // Arrange + IList input = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi there") + ]; + + // Act + IList result = await DefaultTransforms.ToOutput>(input); + + // Assert + Assert.Same(input, result); + } + + [Fact] + public async Task ToOutputAsyncWithOutputTypeChatMessageReturnsSingleMessageAsync() + { + // Arrange + IList input = + [ + new(ChatRole.User, "Hello") + ]; + + // Act + ChatMessage result = await DefaultTransforms.ToOutput(input); + + // Assert + Assert.Same(input[0], result); + } + + [Fact] + public async Task ToOutputAsyncWithOutputTypeStringReturnsContentOfSingleMessageAsync() + { + // Arrange + string expected = "Hello, world!"; + IList input = + [ + new(ChatRole.User, expected) + ]; + + // Act + string result = await DefaultTransforms.ToOutput(input); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public async Task ToOutputAsyncWithOutputTypeDeserializableDeserializesFromContentAsync() + { + // Arrange + TestObject expected = new() { Id = 42, Name = "TestName" }; + string json = JsonSerializer.Serialize(expected); + IList input = + [ + new(ChatRole.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 ToOutputAsyncWithInvalidJsonThrowsExceptionAsync() + { + // Arrange + IList input = + [ + new(ChatRole.User, "Not valid JSON") + ]; + + // Act & Assert + await Assert.ThrowsAsync(async () => + await DefaultTransforms.ToOutput(input) + ); + } + + [Fact] + public async Task ToOutputAsyncWithMultipleMessagesAndNonMatchingTypeThrowsExceptionAsync() + { + // Arrange + IList input = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi there") + ]; + + // Act & Assert + await Assert.ThrowsAsync(async () => + await DefaultTransforms.ToOutput(input) + ); + } + + [Fact] + public async Task ToOutputAsyncWithNullContentHandlesGracefullyAsync() + { + // Arrange + IList input = + [ + new(ChatRole.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/tests/Microsoft.Agents.Orchestration.UnitTests/GroupChatOrchestrationTests.cs b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/GroupChatOrchestrationTests.cs new file mode 100644 index 0000000000..f336970dda --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/GroupChatOrchestrationTests.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Agents.Orchestration.GroupChat; +using Microsoft.Extensions.AI.Agents; +using Microsoft.SemanticKernel.Agents.Runtime.InProcess; + +namespace Microsoft.Agents.Orchestration.UnitTest; + +/// +/// Tests for the class. +/// +public class GroupChatOrchestrationTests +{ + [Fact] + public async Task GroupChatOrchestrationWithSingleAgentAsync() + { + // Arrange + await using InProcessRuntime runtime = new(); + MockAgent mockAgent1 = MockAgent.CreateWithResponse(2, "xyz"); + + // Act: Create and execute the orchestration + string response = await ExecuteOrchestrationAsync(runtime, mockAgent1); + + // Assert + Assert.Equal(1, mockAgent1.InvokeCount); + Assert.Equal("xyz", response); + } + + [Fact] + public async Task GroupChatOrchestrationWithMultipleAgentsAsync() + { + // Arrange + await using InProcessRuntime runtime = new(); + + MockAgent mockAgent1 = MockAgent.CreateWithResponse(1, "abc"); + MockAgent mockAgent2 = MockAgent.CreateWithResponse(2, "xyz"); + MockAgent mockAgent3 = MockAgent.CreateWithResponse(3, "lmn"); + + // Act: Create and execute the orchestration + string response = await ExecuteOrchestrationAsync(runtime, mockAgent1, mockAgent2, mockAgent3); + + // Assert + Assert.Equal(1, mockAgent1.InvokeCount); + Assert.Equal(1, mockAgent2.InvokeCount); + Assert.Equal(1, mockAgent3.InvokeCount); + Assert.Equal("lmn", response); + } + + 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; + } +} diff --git a/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/HandoffOrchestrationTests.cs b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/HandoffOrchestrationTests.cs new file mode 100644 index 0000000000..6a5f5c3359 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/HandoffOrchestrationTests.cs @@ -0,0 +1,243 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Orchestration.Handoff; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; +using Microsoft.SemanticKernel.Agents.Runtime.InProcess; +using OpenAI; + +namespace Microsoft.Agents.Orchestration.UnitTest; + +/// +/// Tests for the class. +/// +public sealed 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 + Agent 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(Skip = "Incomplete mock responses")] + public async Task HandoffOrchestrationWithMultipleAgentsAsync() + { + // Arrange + Agent mockAgent1 = + this.CreateMockAgent( + "Agent1", + "Test Agent", + Responses.Handoff("Agent2")); + Agent mockAgent2 = + this.CreateMockAgent( + "Agent2", + "Test Agent", + Responses.Result("Final response")); + Agent 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 ChatClientAgent CreateMockAgent(string name, string description, params string[] responses) + { + HttpMessageHandlerStub messageHandlerStub = new(); + foreach (string response in responses) + { + HttpResponseMessage responseMessage = + new() + { + StatusCode = System.Net.HttpStatusCode.OK, + Content = new StringContent(response), + }; + messageHandlerStub.ResponseQueue.Enqueue(responseMessage); + this._disposables.Add(responseMessage); + } + HttpClient httpClient = new(messageHandlerStub, disposeHandler: false); + + this._disposables.Add(messageHandlerStub); + this._disposables.Add(httpClient); + + OpenAIClientOptions clientOptions = + new() + { + Transport = new HttpClientPipelineTransport(httpClient), + RetryPolicy = new ClientRetryPolicy(maxRetries: 0), + NetworkTimeout = Timeout.InfiniteTimeSpan, + }; + IChatClient chatClient = + new OpenAIClient(new ApiKeyCredential("fake-key"), clientOptions) + .GetChatClient("Any Model") + .AsIChatClient() + .AsBuilder() + .UseFunctionInvocation() + .Build(); + + ChatClientAgentOptions agentOptions = new() { Name = name, Description = description }; + ChatClientAgent mockAgent = new(chatClient, agentOptions); + + return mockAgent; + } + + 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": "transfer_to_{{{agentName}}}", + "arguments": "{}" + } + } + ] + } + } + ], + "usage": { + "prompt_tokens": 52, + "completion_tokens": 1, + "total_tokens": 53 + } + } + """; + + public static string Result(string summary) => + $$$""" + { + "id": "chat-234", + "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": "end_task_with_summary", + "arguments": "{ \"summary\": \"{{{summary}}}\" }" + } + } + ] + } + } + ] + } + """; + } +} diff --git a/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/HandoffsTests.cs b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/HandoffsTests.cs new file mode 100644 index 0000000000..b2878e0676 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/HandoffsTests.cs @@ -0,0 +1,236 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Agents.Orchestration.Handoff; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; +using Moq; + +namespace Microsoft.Agents.Orchestration.UnitTest; + +public class HandoffsTests +{ + [Fact] + public void EmptyConstructorsCreateEmptyCollections() + { + AgentHandoffs agentHandoffs = []; + Assert.Empty(agentHandoffs); + + OrchestrationHandoffs orchestrationHandoffs = new("first"); + Assert.Empty(orchestrationHandoffs); + Assert.Equal("first", orchestrationHandoffs.FirstAgentName); + } + + [Fact] + public void DictionaryConstructorsInvalidFirstAgent() + { + Assert.Throws(() => new OrchestrationHandoffs((string)null!)); + Assert.Throws(() => new OrchestrationHandoffs(string.Empty)); + Assert.Throws(() => new OrchestrationHandoffs(" ")); + } + + [Fact] + public void AddWithAgentObjectsCreatesHandoffRelationships() + { + // 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 AddWithAgentAndCustomDescriptionUsesCustomDescription() + { + // 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 AddWithAgentAndTargetNameAddsHandoffWithDescription() + { + // 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 AddWithSourceNameAndTargetNameAddsHandoffWithDescription() + { + // Arrange + OrchestrationHandoffs handoffs = new("sourceName"); + + string sourceName = "sourceName"; + string targetName = "targetName"; + string description = "Target description"; + + // Act + handoffs.Add(sourceName, targetName, description); + + // Assert + Assert.Single(handoffs); + Assert.Equal("sourceName", handoffs.FirstAgentName); + AgentHandoffs sourceHandoffs = handoffs[sourceName]; + Assert.Single(sourceHandoffs); + Assert.Equal(description, sourceHandoffs[targetName]); + } + + [Fact] + public void AddWithMultipleSourcesAndTargetsCreatesCorrectStructure() + { + // Arrange + OrchestrationHandoffs handoffs = new("source1"); + + 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 StaticAddCreatesNewOrchestrationHandoffs() + { + // 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 AddWithAgentsWithNoNameUsesId() + { + // 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 AddWithTargetWithNoDescriptionUsesEmptyString() + { + // 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 ChatClientAgent CreateAgent(string id, string? description = null, string? name = null) + { + Mock mockClient = new(MockBehavior.Loose); + ChatClientAgentOptions options = + new() + { + Id = id, + Name = name, + Description = description, + }; + ChatClientAgent mockAgent = new(mockClient.Object, options); + return mockAgent; + } +} diff --git a/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/HttpMessageHandlerStub.cs b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/HttpMessageHandlerStub.cs new file mode 100644 index 0000000000..3d199d36ea --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/HttpMessageHandlerStub.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.Orchestration.UnitTest; + +internal sealed class HttpMessageHandlerStub : HttpMessageHandler +{ + public Queue ResponseQueue { get; } = new(); + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(this.ResponseQueue.Dequeue()); + } +} diff --git a/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/Microsoft.Agents.Orchestration.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/Microsoft.Agents.Orchestration.UnitTests.csproj new file mode 100644 index 0000000000..e1d66b73df --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/Microsoft.Agents.Orchestration.UnitTests.csproj @@ -0,0 +1,18 @@ + + + + $(ProjectsTargetFrameworks) + $(ProjectsDebugTargetFrameworks) + + + + + + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/MockAgent.cs b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/MockAgent.cs new file mode 100644 index 0000000000..671a5a2c4b --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/MockAgent.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; +using Moq; + +namespace Microsoft.Agents.Orchestration.UnitTest; + +/// +/// Mock definition of . +/// +internal sealed class MockAgent(int index) : Agent +{ + public static MockAgent CreateWithResponse(int index, string response) + { + return new(index) + { + Response = [new(ChatRole.Assistant, response)] + }; + } + + public int InvokeCount { get; private set; } + + public IReadOnlyList Response { get; set; } = []; + + public override string? Name => $"testagent{index}"; + + public override string? Description => $"test {index}"; + + public override AgentThread GetNewThread() + { + return new AgentThread() { Id = Guid.NewGuid().ToString() }; + } + + public override async Task RunAsync(IReadOnlyCollection messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + this.InvokeCount++; + if (thread == null) + { + Mock mockThread = new(MockBehavior.Strict); + thread = mockThread.Object; + } + + await (options?.OnIntermediateMessages?.Invoke(this.Response) ?? Task.CompletedTask); + + return new ChatResponse(messages: [.. this.Response]); + } + + public override async IAsyncEnumerable RunStreamingAsync(IReadOnlyCollection messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + this.InvokeCount++; + + await (options?.OnIntermediateMessages?.Invoke(this.Response) ?? Task.CompletedTask); + + foreach (ChatMessage message in this.Response) + { + yield return new ChatResponseUpdate(message.Role, message.Text); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/OrchestrationResultTests.cs b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/OrchestrationResultTests.cs new file mode 100644 index 0000000000..875ecd5bbb --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/OrchestrationResultTests.cs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.SemanticKernel.Agents.Runtime; + +namespace Microsoft.Agents.Orchestration.UnitTest; + +public class OrchestrationResultTests +{ + [Fact] + public void ConstructorInitializesPropertiesCorrectly() + { + // Arrange + OrchestrationContext context = new("TestOrchestration", new TopicId("testTopic"), null, 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 GetValueAsyncReturnsCompletedValueWhenTaskIsCompletedAsync() + { + // Arrange + OrchestrationContext context = new("TestOrchestration", new TopicId("testTopic"), null, 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 GetValueAsyncWithTimeoutReturnsCompletedValueWhenTaskCompletesWithinTimeoutAsync() + { + // Arrange + OrchestrationContext context = new("TestOrchestration", new TopicId("testTopic"), null, 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 GetValueAsyncWithTimeoutThrowsTimeoutExceptionWhenTaskDoesNotCompleteWithinTimeoutAsync() + { + // Arrange + OrchestrationContext context = new("TestOrchestration", new TopicId("testTopic"), null, 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 GetValueAsyncReturnsCompletedValueWhenCompletionIsDelayedAsync() + { + // Arrange + OrchestrationContext context = new("TestOrchestration", new TopicId("testTopic"), null, 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/tests/Microsoft.Agents.Orchestration.UnitTests/SequentialOrchestrationTests.cs b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/SequentialOrchestrationTests.cs new file mode 100644 index 0000000000..a366f132db --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/SequentialOrchestrationTests.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Agents.Orchestration.Sequential; +using Microsoft.Extensions.AI.Agents; +using Microsoft.SemanticKernel.Agents.Runtime.InProcess; + +namespace Microsoft.Agents.Orchestration.UnitTest; + +/// +/// Tests for the class. +/// +public class SequentialOrchestrationTests +{ + [Fact] + public async Task SequentialOrchestrationWithSingleAgentAsync() + { + // Arrange + await using InProcessRuntime runtime = new(); + MockAgent mockAgent1 = MockAgent.CreateWithResponse(2, "xyz"); + + // Act: Create and execute the orchestration + string response = await ExecuteOrchestrationAsync(runtime, mockAgent1); + + // Assert + Assert.Equal(1, mockAgent1.InvokeCount); + Assert.Equal("xyz", response); + } + + [Fact] + public async Task SequentialOrchestrationWithMultipleAgentsAsync() + { + // Arrange + await using InProcessRuntime runtime = new(); + + MockAgent mockAgent1 = MockAgent.CreateWithResponse(1, "abc"); + MockAgent mockAgent2 = MockAgent.CreateWithResponse(2, "xyz"); + MockAgent mockAgent3 = MockAgent.CreateWithResponse(3, "lmn"); + + // Act: Create and execute the orchestration + string response = await ExecuteOrchestrationAsync(runtime, mockAgent1, mockAgent2, mockAgent3); + + // Assert + Assert.Equal(1, mockAgent1.InvokeCount); + Assert.Equal(1, mockAgent2.InvokeCount); + Assert.Equal(1, mockAgent3.InvokeCount); + Assert.Equal("lmn", response); + } + + 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; + } +}