diff --git a/dotnet/AutoGen.sln b/dotnet/AutoGen.sln index 21c1a62a3195..94adc3c6654e 100644 --- a/dotnet/AutoGen.sln +++ b/dotnet/AutoGen.sln @@ -80,6 +80,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AutoGen.Agents", "src\Microsoft.AutoGen\Agents\Microsoft.AutoGen.Agents.csproj", "{FD87BD33-4616-460B-AC85-A412BA08BB78}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AutoGen.AgentChat", "src\Microsoft.AutoGen\AgentChat\Microsoft.AutoGen.AgentChat.csproj", "{94D45CDD-4D33-40CC-AD00-57785DA84997}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AutoGen.Abstractions", "src\Microsoft.AutoGen\Abstractions\Microsoft.AutoGen.Abstractions.csproj", "{E0C991D9-0DB8-471C-ADC9-5FB16E2A0106}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AutoGen.Extensions.SemanticKernel", "src\Microsoft.AutoGen\Extensions\SemanticKernel\Microsoft.AutoGen.Extensions.SemanticKernel.csproj", "{952827D4-8D4C-4327-AE4D-E8D25811EF35}" @@ -112,8 +114,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DevTeam.Backend", "samples\ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DevTeam.Shared", "samples\dev-team\DevTeam.Shared\DevTeam.Shared.csproj", "{01F5D7C3-41EB-409C-9B77-A945C07FA7E8}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Hello", "Hello", "{7EB336C2-7C0A-4BC8-80C6-A3173AB8DC45}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Backend", "samples\Hello\Backend\Backend.csproj", "{C428C6E5-B0E5-4DA6-B0F7-43013D2ECE69}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hello.AppHost", "samples\Hello\Hello.AppHost\Hello.AppHost.csproj", "{09A373A0-8169-409F-8C37-3FBC1654B122}" @@ -256,6 +256,10 @@ Global {FD87BD33-4616-460B-AC85-A412BA08BB78}.Debug|Any CPU.Build.0 = Debug|Any CPU {FD87BD33-4616-460B-AC85-A412BA08BB78}.Release|Any CPU.ActiveCfg = Release|Any CPU {FD87BD33-4616-460B-AC85-A412BA08BB78}.Release|Any CPU.Build.0 = Release|Any CPU + {94D45CDD-4D33-40CC-AD00-57785DA84997}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {94D45CDD-4D33-40CC-AD00-57785DA84997}.Debug|Any CPU.Build.0 = Debug|Any CPU + {94D45CDD-4D33-40CC-AD00-57785DA84997}.Release|Any CPU.ActiveCfg = Release|Any CPU + {94D45CDD-4D33-40CC-AD00-57785DA84997}.Release|Any CPU.Build.0 = Release|Any CPU {E0C991D9-0DB8-471C-ADC9-5FB16E2A0106}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E0C991D9-0DB8-471C-ADC9-5FB16E2A0106}.Debug|Any CPU.Build.0 = Debug|Any CPU {E0C991D9-0DB8-471C-ADC9-5FB16E2A0106}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -383,6 +387,7 @@ Global {42A8251C-E7B3-47BB-A82E-459952EBE132} = {F823671B-3ECA-4AE6-86DA-25E920D3FE64} {4BB66E06-37D8-45A0-9B97-DE590AFBA340} = {18BF8DD7-0585-48BF-8F97-AD333080CE06} {FD87BD33-4616-460B-AC85-A412BA08BB78} = {18BF8DD7-0585-48BF-8F97-AD333080CE06} + {94D45CDD-4D33-40CC-AD00-57785DA84997} = {18BF8DD7-0585-48BF-8F97-AD333080CE06} {E0C991D9-0DB8-471C-ADC9-5FB16E2A0106} = {18BF8DD7-0585-48BF-8F97-AD333080CE06} {952827D4-8D4C-4327-AE4D-E8D25811EF35} = {18BF8DD7-0585-48BF-8F97-AD333080CE06} {668726B9-77BC-45CF-B576-0F0773BF1615} = {686480D7-8FEC-4ED3-9C5D-CEBE1057A7ED} @@ -399,11 +404,6 @@ Global {63280C12-3BE3-4C4E-805E-584CDC6BC1F5} = {05B9C173-6441-4DCA-9AC4-E897EF75F331} {EDA3EF83-FC7F-4BCF-945D-B893620EE4B1} = {05B9C173-6441-4DCA-9AC4-E897EF75F331} {01F5D7C3-41EB-409C-9B77-A945C07FA7E8} = {05B9C173-6441-4DCA-9AC4-E897EF75F331} - {7EB336C2-7C0A-4BC8-80C6-A3173AB8DC45} = {686480D7-8FEC-4ED3-9C5D-CEBE1057A7ED} - {C428C6E5-B0E5-4DA6-B0F7-43013D2ECE69} = {7EB336C2-7C0A-4BC8-80C6-A3173AB8DC45} - {09A373A0-8169-409F-8C37-3FBC1654B122} = {7EB336C2-7C0A-4BC8-80C6-A3173AB8DC45} - {A20B9894-F352-4338-872A-F215A241D43D} = {7EB336C2-7C0A-4BC8-80C6-A3173AB8DC45} - {8F7560CF-EEBB-4333-A69F-838CA40FD85D} = {7EB336C2-7C0A-4BC8-80C6-A3173AB8DC45} {97550E87-48C6-4EBF-85E1-413ABAE9DBFD} = {18BF8DD7-0585-48BF-8F97-AD333080CE06} {64EF61E7-00A6-4E5E-9808-62E10993A0E5} = {7EB336C2-7C0A-4BC8-80C6-A3173AB8DC45} {65059914-5527-4A00-9308-9FAF23D5E85A} = {18BF8DD7-0585-48BF-8F97-AD333080CE06} diff --git a/dotnet/src/Microsoft.AutoGen/Abstractions/ICodeExecutor.cs b/dotnet/src/Microsoft.AutoGen/Abstractions/ICodeExecutor.cs new file mode 100644 index 000000000000..3e98148b9c7f --- /dev/null +++ b/dotnet/src/Microsoft.AutoGen/Abstractions/ICodeExecutor.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ICodeExecutor.cs + +namespace Microsoft.AutoGen.Abstractions; + +// TODO: Should these be classes? +public struct CodeBlock +{ + public required string Code { get; set; } + public required string Language { get; set; } // TODO: We should raise this into the routing type, somehow +} + +public struct CodeResult +{ + public required int ExitCode { get; set; } + public required string Output { get; set; } +} + +public interface ICodeExecutor +{ + ValueTask ExecuteCodeBlocksAsync(IEnumerable codeBlocks, CancellationToken cancellationToken = default); + ValueTask RestartAsync(); +} diff --git a/dotnet/src/Microsoft.AutoGen/Abstractions/IHandle.cs b/dotnet/src/Microsoft.AutoGen/Abstractions/IHandle.cs index ff43852b14e5..d90c3e1c99ca 100644 --- a/dotnet/src/Microsoft.AutoGen/Abstractions/IHandle.cs +++ b/dotnet/src/Microsoft.AutoGen/Abstractions/IHandle.cs @@ -8,7 +8,47 @@ public interface IHandle Task HandleObject(object item); } -public interface IHandle : IHandle +public interface IHandle : IHandle { + // TODO: Should this be a ValueTask? Task Handle(T item); } + +public interface IHandleEx : IHandle +{ + Task IHandle.Handle(TIn item) + { + return this.HandleAsync(item, CancellationToken.None).AsTask(); + } + + ValueTask HandleAsync(TIn item) + { + return this.HandleAsync(item, CancellationToken.None); + } + + ValueTask HandleAsync(TIn item, CancellationToken cancellationToken); +} + +public interface IHandleEx // TODO: Map this to IHandle<> somehow? +{ + ValueTask HandleAsync(TIn item) + { + return this.HandleAsync(item, CancellationToken.None); + } + + ValueTask HandleAsync(TIn item, CancellationToken cancellationToken); +} + +public interface IHandleDefault : IHandleEx +{ +} + +public interface IHandleStream +{ + IAsyncEnumerable StreamAsync(TIn item) + { + return this.StreamAsync(item, CancellationToken.None); + } + + IAsyncEnumerable StreamAsync(TIn item, CancellationToken cancellationToken); +} diff --git a/dotnet/src/Microsoft.AutoGen/Abstractions/Microsoft.AutoGen.Abstractions.csproj b/dotnet/src/Microsoft.AutoGen/Abstractions/Microsoft.AutoGen.Abstractions.csproj index 39a90664057e..3f617af19007 100644 --- a/dotnet/src/Microsoft.AutoGen/Abstractions/Microsoft.AutoGen.Abstractions.csproj +++ b/dotnet/src/Microsoft.AutoGen/Abstractions/Microsoft.AutoGen.Abstractions.csproj @@ -12,6 +12,7 @@ + diff --git a/dotnet/src/Microsoft.AutoGen/AgentChat/Abstractions/ChatAgent.cs b/dotnet/src/Microsoft.AutoGen/AgentChat/Abstractions/ChatAgent.cs new file mode 100644 index 000000000000..0995f83d8621 --- /dev/null +++ b/dotnet/src/Microsoft.AutoGen/AgentChat/Abstractions/ChatAgent.cs @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ChatAgent.cs + +using System.Text.RegularExpressions; +using Microsoft.AutoGen.Abstractions; + +namespace Microsoft.AutoGen.AgentChat.Abstractions; + +public struct AgentName +{ + // To ensure parity with Python, we require agent names to be identifiers + // TODO: Ensure that only valid C# identifiers can pass the validation on Python? + + /* + From https://docs.python.org/3/reference/lexical_analysis.html#identifiers: + ``` + identifier ::= xid_start xid_continue* + id_start ::= + id_continue ::= + xid_start ::= + xid_continue ::= + ``` + + Note: we are not going to deal with normalization; it would require a lot of effort for likely little gain + (this will mean that, strictly speaking, .NET will support a subset of the identifiers that Python does) + + The Unicode category codes mentioned above stand for: + + * Lu - uppercase letters + * Ll - lowercase letters + * Lt - titlecase letters + * Lm - modifier letters + * Lo - other letters + * Nl - letter numbers* + * Mn - nonspacing marks + * Mc - spacing combining marks* + * Nd - decimal numbers + * Pc - connector punctuations + + Of these, most are captured by "word characters" in .NET, \w, only needing \p{Nl} and \p{Mc} to be added. + While Copilot /thinks/ that \p{Pc} is needed, it is not, as it is part of \w in .NET. + + * Other_ID_Start - explicit list of characters in PropList.txt to support backwards compatibility + * Other_ID_Continue - likewise + + # ================================================ + + 1885..1886 ; Other_ID_Start # Mn [2] MONGOLIAN LETTER ALI GALI BALUDA..MONGOLIAN LETTER ALI GALI THREE BALUDA + 2118 ; Other_ID_Start # Sm SCRIPT CAPITAL P + 212E ; Other_ID_Start # So ESTIMATED SYMBOL + 309B..309C ; Other_ID_Start # Sk [2] KATAKANA-HIRAGANA VOICED SOUND MARK..KATAKANA-HIRAGANA SEMI-VOICED SOUND MARK + + # Total code points: 6 + + The pattern for this in .NET is [\u1185-\u1186\u2118\u212E\u309B-\u309C] + + # ================================================ + + 00B7 ; Other_ID_Continue # Po MIDDLE DOT + 0387 ; Other_ID_Continue # Po GREEK ANO TELEIA + 1369..1371 ; Other_ID_Continue # No [9] ETHIOPIC DIGIT ONE..ETHIOPIC DIGIT NINE + 19DA ; Other_ID_Continue # No NEW TAI LUE THAM DIGIT ONE + 200C..200D ; Other_ID_Continue # Cf [2] ZERO WIDTH NON-JOINER..ZERO WIDTH JOINER + 30FB ; Other_ID_Continue # Po KATAKANA MIDDLE DOT + FF65 ; Other_ID_Continue # Po HALFWIDTH KATAKANA MIDDLE DOT + + # Total code points: 16 + + The pattern for this in .NET is [\u00B7\u0387\u1369-\u1371\u19DA\u200C\u200D\u30FB\uFF65] + + # ================================================ + + Classes for "IdStart": {Lu, Ll, Lt, Lm, Lo, Nl, '_', Other_ID_Start} + pattern: [\p{Lu}\p{Ll}\p{Lt}\p{Lm}\p{Lo}\p{Nl}_\u1185-\u1186\u2118\u212E\u309B-\u309C] + + Classes for "IdContinue": {\w, Nl, Mc, Other_ID_Start, Other_ID_Continue} + pattern: [\w\p{Nl}\p{Mc}_\u1185-\u1186\u2118\u212E\u309B-\u309C\u00B7\u0387\u1369-\u1371\u19DA\u200C\u200D\u30FB\uFF65] + + Match group for identifiers: + (?(?:[\p{Lu}\p{Ll}\p{Lt}\p{Lm}\p{Lo}\p{Nl}_\u1185-\u1186\u2118\u212E\u309B-\u309C])(?:[\w\p{Nl}\p{Mc}_\u1185-\u1186\u2118\u212E\u309B-\u309C\u00B7\u0387\u1369-\u1371\u19DA\u200C\u200D\u30FB\uFF65])*) + */ + + private const string IdStartClass = @"[\p{Lu}\p{Ll}\p{Lt}\p{Lm}\p{Lo}\p{Nl}_\u1185-\u1186\u2118\u212E\u309B-\u309C]"; + private const string IdContinueClass = @"[\w\p{Nl}\p{Mc}_\u1185-\u1186\u2118\u212E\u309B-\u309C\u00B7\u0387\u1369-\u1371\u19DA\u200C\u200D\u30FB\uFF65]"; + + private static readonly Regex AgentNameRegex = new Regex($"^{IdStartClass}{IdContinueClass}*$", RegexOptions.Compiled | RegexOptions.Singleline); + + public string Name { get; } + + public AgentName(string name) + { + AgentName.CheckValid(name); + + this.Name = name; + } + + public static bool IsValid(string name) => AgentNameRegex.IsMatch(name); + + public static void CheckValid(string name) + { + if (!AgentName.IsValid(name)) + { + throw new ArgumentException($"Agent name '{name}' is not a valid identifier."); + } + } + + // Implicit cast to string + public static implicit operator string(AgentName agentName) => agentName.Name; +} + +public class Response +{ + public required ChatMessage Message { get; set; } + + public List? InnerMessages { get; set; } +} + +public class StreamingFrame() where TInternalMessage : AgentMessage +{ + public enum FrameType + { + InternalMessage, + Response + } + + public FrameType Type { get; set; } + + public TInternalMessage? InternalMessage { get; set; } + public TResponse? Response { get; set; } +} + +public class StreamingFrame : StreamingFrame; + +public class ChatStreamFrame : StreamingFrame; + +public interface IChatAgent : + IHandleEx, Response>, + IHandleStream, ChatStreamFrame> +{ + AgentName Name { get; } + string Description { get; } + + IEnumerable ProducedMessageTypes { get; } // TODO: Is there a way to make this part of the type somehow? + // Annotations, or IProduce<>? Do we ever actually access this? + + ValueTask ResetAsync(CancellationToken cancellationToken); +} diff --git a/dotnet/src/Microsoft.AutoGen/AgentChat/Abstractions/Handoff.cs b/dotnet/src/Microsoft.AutoGen/AgentChat/Abstractions/Handoff.cs new file mode 100644 index 000000000000..58ae427e5d6e --- /dev/null +++ b/dotnet/src/Microsoft.AutoGen/AgentChat/Abstractions/Handoff.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Handoff.cs + +namespace Microsoft.AutoGen.AgentChat.Abstractions; + +public class Handoff(string target, string? description = null, string? name = null, string? message = null) +{ + private static string? CheckName(string? name) + { + if (name != null && !AgentName.IsValid(name)) + { + throw new ArgumentException($"Handoff name '{name}' is not a valid identifier."); + } + + return name; + } + + public AgentName Target { get; } = new AgentName(target); + public string Description { get; } = description ?? $"Handoff to {target}"; + public string Name { get; } = CheckName(name) ?? $"transfer_to_{target.ToLowerInvariant()}"; + public string Message { get; } = message ?? $"Transferred to {target}, adopting the role of {target} immediately."; + + private string DoHandoff() => this.Message; + + public ITool HandoffTool => new CallableTool(this.Name, this.Description, this.DoHandoff); +} diff --git a/dotnet/src/Microsoft.AutoGen/AgentChat/Abstractions/ITeam.cs b/dotnet/src/Microsoft.AutoGen/AgentChat/Abstractions/ITeam.cs new file mode 100644 index 000000000000..b60b76c38722 --- /dev/null +++ b/dotnet/src/Microsoft.AutoGen/AgentChat/Abstractions/ITeam.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ITeam.cs + +namespace Microsoft.AutoGen.AgentChat.Abstractions; + +public interface ITeam : ITaskRunner +{ + ValueTask ResetAsync(CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.AutoGen/AgentChat/Abstractions/Messages.cs b/dotnet/src/Microsoft.AutoGen/AgentChat/Abstractions/Messages.cs new file mode 100644 index 000000000000..82cc82df327d --- /dev/null +++ b/dotnet/src/Microsoft.AutoGen/AgentChat/Abstractions/Messages.cs @@ -0,0 +1,278 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Messages.cs + +using System.Collections; +using System.Diagnostics; +using System.Text; +using Microsoft.Extensions.AI; + +namespace Microsoft.AutoGen.AgentChat.Abstractions; + +// Needed abstractions +// TODO: These should come from Protos for x-lang support + +public abstract class BaseMessage +{ + public required string Source { get; set; } +} + +public abstract class AgentMessage : BaseMessage +{ + public Microsoft.Extensions.AI.ChatMessage ToCompletionClientMessage() + => ToCompletionClientMessage(role: ChatRole.Assistant); + + public abstract Microsoft.Extensions.AI.ChatMessage ToCompletionClientMessage(ChatRole role); +} + +/// +/// Messages for agent-to-agent communication. +/// +public abstract class ChatMessage : AgentMessage +{ +} + +// Leaf Classes +public class TextMessage : ChatMessage +{ + public required string Content { get; set; } + + public override Microsoft.Extensions.AI.ChatMessage ToCompletionClientMessage(ChatRole role) + { + return new Microsoft.Extensions.AI.ChatMessage(role, this.Content) { AuthorName = this.Source }; + } +} + +public struct MultiModalData +{ + public enum Type + { + String, Image + } + + public static MultiModalData CheckTypeAndCreate(AIContent item) + { + if (item is TextContent text) + { + return new MultiModalData(text); + } + else if (item is ImageContent image) + { + return new MultiModalData(image); + } + else + { + throw new ArgumentException("Only TextContent and ImageContent are allowed in MultiModalMessage"); + } + } + + public MultiModalData(string text) + { + ContentType = Type.String; + AIContent = new TextContent(text); + } + + public MultiModalData(ImageContent image) + { + ContentType = Type.Image; + AIContent = image; + } + + public MultiModalData(TextContent textContent) + { + ContentType = Type.String; + AIContent = textContent; + } + + public Type ContentType { get; } + + // TODO: Make this into a real enum? + public AIContent AIContent { get; } +} + +public class MultiModalMessage : ChatMessage, IList +{ + public AIContent this[int index] { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + + public List Content { get; private set; } = new List(); + + public int Count => this.Content.Count; + + public bool IsReadOnly => false; + + public void Add(AIContent item) + { + this.Content.Add(MultiModalData.CheckTypeAndCreate(item)); + } + + public void Clear() + { + this.Content.Clear(); + } + + public bool Contains(AIContent item) + { + return this.Content.Any(x => x.AIContent == item); + } + + public void CopyTo(AIContent[] array, int arrayIndex) + { + if (array == null) + { + throw new ArgumentNullException(nameof(array)); + } + + if (arrayIndex < 0 || arrayIndex >= array.Length) + { + throw new ArgumentOutOfRangeException(nameof(arrayIndex)); + } + + if (array.Length - arrayIndex < this.Content.Count) + { + throw new ArgumentException("The number of elements in the source is greater than the available space from arrayIndex to the end of the destination array."); + } + + for (var i = 0; i < this.Content.Count; i++) + { + array[arrayIndex + i] = this.Content[i].AIContent; + } + } + + public IEnumerator GetEnumerator() + { + return this.Content.Select(x => x.AIContent).GetEnumerator(); + } + + public int IndexOf(AIContent item) + { + return this.Content.FindIndex(x => x.AIContent == item); + } + + public void Insert(int index, AIContent item) + { + this.Content.Insert(index, MultiModalData.CheckTypeAndCreate(item)); + } + + public bool Remove(AIContent item) + { + int targetIndex = Content.FindIndex(x => x.AIContent == item); + if (targetIndex == -1) + { + return false; + } + + this.Content.RemoveAt(targetIndex); + return true; + } + + public void RemoveAt(int index) + { + this.Content.RemoveAt(index); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + public override Microsoft.Extensions.AI.ChatMessage ToCompletionClientMessage(ChatRole role) + { + StringBuilder contentBuilder = new StringBuilder(); + foreach (MultiModalData item in this.Content) + { + if (item.ContentType == MultiModalData.Type.String) + { + contentBuilder.AppendLine(item.AIContent.RawRepresentation as string ?? ""); + } + else if (item.ContentType == MultiModalData.Type.Image) + { + contentBuilder.AppendLine("[Image]"); + } + } + + return new Microsoft.Extensions.AI.ChatMessage(role, contentBuilder.ToString()) { AuthorName = this.Source }; + } +} + +public class HandoffMessage : ChatMessage // TODO: Should this be InternalMessage? +{ + public required string Target { get; set; } + + public required string Content { get; set; } + + public override Microsoft.Extensions.AI.ChatMessage ToCompletionClientMessage(ChatRole role) + { + Debug.Assert(role == ChatRole.Assistant, "HandoffMessage can only come from agents in the Assistant Role"); + return new Microsoft.Extensions.AI.ChatMessage(ChatRole.Assistant, this.Content) { AuthorName = this.Source }; + } +} + +// TODO: Should this be part of the Autogen "Core" (and what does that even mean on the .NET side?) +//public partial class FunctionCall { } +public partial class FunctionExecutionResult { } + +public class ToolCallMessage : AgentMessage +{ + public List Content { get; private set; } = new List(); + + public override Microsoft.Extensions.AI.ChatMessage ToCompletionClientMessage(ChatRole role) + { + Debug.Assert(role == ChatRole.Assistant, "ToolCallMessage can only come from agents in the Assistant Role"); + return new Microsoft.Extensions.AI.ChatMessage(ChatRole.Assistant, (IList)this.Content) { AuthorName = this.Source }; + } +} + +public class ToolCallResultMessage : AgentMessage +{ + public List Content { get; private set; } = new List(); + + public override Microsoft.Extensions.AI.ChatMessage ToCompletionClientMessage(ChatRole role) + { + Debug.Assert(role == ChatRole.Tool, "ToolCallResultMessage can only come from agents in the Tool Role"); + return new Microsoft.Extensions.AI.ChatMessage(ChatRole.Tool, (IList)this.Content) { AuthorName = this.Source }; + } +} + +public class StopMessage : ChatMessage +{ + public required string Content { get; set; } + + public override Microsoft.Extensions.AI.ChatMessage ToCompletionClientMessage(ChatRole role) + { + Debug.Assert(role == ChatRole.Assistant, "StopMessage can only come from agents in the Assistant Role"); + return new Microsoft.Extensions.AI.ChatMessage(ChatRole.Assistant, this.Content) { AuthorName = this.Source }; + } +} + +public static class CompletionChatMessageExtensions +{ + public static Microsoft.Extensions.AI.ChatMessage Flatten(this Microsoft.Extensions.AI.ChatMessage msg) + { + if (msg.Contents.Count == 1 && msg.Contents[0] is TextContent) + { + return msg; + } + + StringBuilder contentBuilder = new StringBuilder(); + foreach (AIContent content in msg.Contents) + { + if (content is TextContent textContent) + { + contentBuilder.AppendLine(textContent.Text); + } + else if (content is ImageContent) + { + contentBuilder.AppendLine("[Image]"); + } + else + { + contentBuilder.AppendLine($"[{content.GetType().Name}]"); + } + } + + return new Microsoft.Extensions.AI.ChatMessage(msg.Role, contentBuilder.ToString()) + { + AuthorName = msg.AuthorName, + AdditionalProperties = msg.AdditionalProperties + }; + } +} diff --git a/dotnet/src/Microsoft.AutoGen/AgentChat/Abstractions/OutputCollectorAgent.cs b/dotnet/src/Microsoft.AutoGen/AgentChat/Abstractions/OutputCollectorAgent.cs new file mode 100644 index 000000000000..0790fc763d31 --- /dev/null +++ b/dotnet/src/Microsoft.AutoGen/AgentChat/Abstractions/OutputCollectorAgent.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// OutputCollectorAgent.cs + +using System.Diagnostics; +using Microsoft.AutoGen.Abstractions; +using Microsoft.AutoGen.AgentChat.GroupChat; +using Microsoft.AutoGen.Agents; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AutoGen.AgentChat.Abstractions; + +// TODO: Abstract the core logic of this out into the equivalent of ClosureAgent, because that seems like a +// useful facility to have in Core +internal sealed class OutputCollectorAgent : AgentBase, + IHandleEx, + IHandleEx, + IHandleEx +{ + public AgentChatBinder binder; + + public OutputCollectorAgent(IAgentRuntime runtime, EventTypes eventTypes, AgentChatBinder binder, ILogger? logger = null) : base(runtime, eventTypes, logger) + { + this.binder = binder; + + this.binder.SubscribeOutput(this); + } + + private void ForwardMessageInternal(ChatMessage message, CancellationToken cancel = default) + { + if (!cancel.IsCancellationRequested) + { + // TODO: Catch write failures? + this.binder.OutputQueue.TryEnqueue(message); + } + } + + public ValueTask HandleAsync(GroupChatStart item, CancellationToken cancel) + { + if (item.Message != null) + { + this.ForwardMessageInternal(item.Message, cancel); + } + + return ValueTask.CompletedTask; + } + + public ValueTask HandleAsync(GroupChatMessage item, CancellationToken cancel) + { + Debug.Assert(item.Message is ChatMessage, "We should never receive internal messages into the output queue?"); + if (item.Message is ChatMessage chatMessage) + { + this.ForwardMessageInternal(chatMessage, cancel); + } + + return ValueTask.CompletedTask; + } + + public ValueTask HandleAsync(GroupChatTermination item, CancellationToken cancel) + { + this.binder.StopReason = item.Message.Content; + return ValueTask.CompletedTask; + } +} diff --git a/dotnet/src/Microsoft.AutoGen/AgentChat/Abstractions/Tasks.cs b/dotnet/src/Microsoft.AutoGen/AgentChat/Abstractions/Tasks.cs new file mode 100644 index 000000000000..9550977acf55 --- /dev/null +++ b/dotnet/src/Microsoft.AutoGen/AgentChat/Abstractions/Tasks.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Tasks.cs + +namespace Microsoft.AutoGen.AgentChat.Abstractions; + +public struct TaskResult(List messages) +{ + public List Messages { get; } = messages; + public string? StopReason = null; +} + +public class TaskFrame : StreamingFrame +{ + public TaskFrame(TaskResult response) + { + this.Response = response; + this.Type = TaskFrame.FrameType.Response; + } + + public TaskFrame(AgentMessage message) + { + this.InternalMessage = message; + this.Type = TaskFrame.FrameType.InternalMessage; + } +} + +public interface ITaskRunner +{ + async ValueTask RunAsync(string task, CancellationToken cancellationToken = default) + { + await foreach (TaskFrame frame in this.StreamAsync(task, cancellationToken)) + { + if (frame.Type == TaskFrame.FrameType.Response) + { + return frame.Response!; + } + } + + throw new InvalidOperationException("The stream should have returned the final result."); + } + + IAsyncEnumerable StreamAsync(string task, CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.AutoGen/AgentChat/Abstractions/Termination.cs b/dotnet/src/Microsoft.AutoGen/AgentChat/Abstractions/Termination.cs new file mode 100644 index 000000000000..6cc56756ec6e --- /dev/null +++ b/dotnet/src/Microsoft.AutoGen/AgentChat/Abstractions/Termination.cs @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Termination.cs + +namespace Microsoft.AutoGen.AgentChat.Abstractions; + +public interface ITerminationCondition +{ + /// + /// Checkes if the termination condition has been reached + /// + bool IsTerminated { get; } + + /// + /// Check if the conversation should be terminated based on the messages received + /// since the last time the condition was called. + /// Return a if the conversation should be terminated, or null otherwise. + /// + /// The messages received since the last time the condition was called. + /// A if the conversation should be terminated, or null + /// otherwise. + /// If the termination condition has already been reached. + ValueTask UpdateAsync(IList messages); + + /// + /// Resets the termination condition. + /// + void Reset(); + + ITerminationCondition Or(ITerminationCondition other) + { + return new CombinerCondition(CombinerCondition.Or, this, other); + } + + ITerminationCondition And(ITerminationCondition other) + { + return new CombinerCondition(CombinerCondition.And, this, other); + } +} + +public sealed class TerminatedException : Exception +{ + public TerminatedException() : base("The termination condition has already been reached.") + { + } +} + +internal sealed class CombinerCondition : ITerminationCondition +{ + public const bool Conjunction = true; + public const bool Disjunction = false; + + public const bool And = Conjunction; + public const bool Or = Disjunction; + + private List stopMessages = new List(); + private List clauses; + private readonly bool conjunction; + + public CombinerCondition(bool conjuction, params IEnumerable clauses) + { + // Flatten the list of clauses by unwrapping included CombinerConditions if their + // conjuctions match (since combiners with associative conjuctions can be hoisted). + IEnumerable flattened = + clauses.SelectMany(c => + (c is CombinerCondition combiner && combiner.conjunction == conjuction) + ? (IEnumerable) combiner.clauses + : new[] { c }); + + this.conjunction = conjuction; + + this.clauses = flattened.ToList(); + } + + public bool IsTerminated { get; private set; } + + public void Reset() + { + this.stopMessages.Clear(); + this.clauses.ForEach(c => c.Reset()); + + this.IsTerminated = false; + } + + public async ValueTask UpdateAsync(IList messages) + { + if (this.IsTerminated) + { + throw new TerminatedException(); + } + + // When operating as a conjunction, we may be accumulated terminating conditions, but we will not fire until + // all of them are complete. In this case, we need to avoid continuing to interact with already terminated + // clauses, because trying to update them will throw + var candidateTerminations = this.conjunction ? this.clauses.Where(clause => !clause.IsTerminated) : clauses; + + // TODO: Do we really need these to be ValueTasks? (Alternatively: Do we really need to run them explicitly + // on every invocation, or is a Worker pattern more appropriate?) + List> tasks = candidateTerminations.Select(c => c.UpdateAsync(messages).AsTask()).ToList(); + StopMessage?[] results = await Task.WhenAll(tasks); + + bool raiseTermination = this.conjunction; // if or, we start with false until we observe a true + // if and, we start with true until we observe a false + + foreach (StopMessage? maybeStop in results) + { + if (maybeStop != null) + { + this.stopMessages.Add(maybeStop); + if (!this.conjunction) + { + // If any clause terminates, the disjunction terminates + raiseTermination = true; + } + } + else if (this.conjunction) + { + // If any clause does not terminate, the conjuction does not terminate + raiseTermination = false; + } + } + + if (raiseTermination) + { + this.IsTerminated = true; + + return new StopMessage + { + Content = string.Join("; ", stopMessages.Select(sm => sm.Content)), + Source = string.Join(", ", stopMessages.Select(sm => sm.Source)) + }; + } + + return null; + } + + ITerminationCondition ITerminationCondition.Or(ITerminationCondition other) + { + if (this.conjunction == Or) + { + this.clauses.Add(other); + return this; + } + else + { + return new CombinerCondition(Or, this, new CombinerCondition(Or, other)); + } + } + + ITerminationCondition ITerminationCondition.And(ITerminationCondition other) + { + if (this.conjunction == And) + { + this.clauses.Add(other); + return this; + } + else + { + return new CombinerCondition(And, this, new CombinerCondition(And, other)); + } + } +} diff --git a/dotnet/src/Microsoft.AutoGen/AgentChat/Abstractions/Tools.cs b/dotnet/src/Microsoft.AutoGen/AgentChat/Abstractions/Tools.cs new file mode 100644 index 000000000000..a534b0858f0e --- /dev/null +++ b/dotnet/src/Microsoft.AutoGen/AgentChat/Abstractions/Tools.cs @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Tools.cs + +using System.ComponentModel; +using System.Reflection; +using Microsoft.Extensions.AI; + +namespace Microsoft.AutoGen.AgentChat.Abstractions; + +public static class ReflectionExtensions +{ + public static AIFunctionParameterMetadata ToAIFunctionMetadata(this ParameterInfo pi) + { + return new AIFunctionParameterMetadata(pi.Name!) + { + Description = pi.GetCustomAttribute()?.Description, + + ParameterType = pi.ParameterType, + + HasDefaultValue = pi.HasDefaultValue, + IsRequired = !pi.HasDefaultValue, + DefaultValue = pi.DefaultValue, + + // Schema = JSONSchema of type + }; + } + + public static AIFunctionReturnParameterMetadata ToAIFunctionReturnMetadata(this ParameterInfo rpi) + { + return new AIFunctionReturnParameterMetadata + { + Description = rpi.GetCustomAttribute()?.Description, + + ParameterType = rpi.ParameterType + + //Schema = JSONSchema of type + }; + } +} + +public class ParameterSchema(string name, Type type, bool isRequired = false, object? defaultValue = default) +{ + public string Name { get; } = name; + public Type Type { get; } = type; + public bool IsRequired { get; } = isRequired; + + public object? DefaultValue { get; } = defaultValue; + + public static implicit operator ParameterSchema(ParameterInfo parameterInfo) + { + Type parameterType = parameterInfo.ParameterType; + return ParameterSchema.Create(parameterType, parameterInfo.Name!, parameterInfo.HasDefaultValue, parameterInfo.DefaultValue); + } + + public static implicit operator ParameterSchema(AIFunctionParameterMetadata parameterMetadata) + { + Type parameterType = parameterMetadata.ParameterType!; // TODO: Deal with missing ParameterTypes + return ParameterSchema.Create(parameterType, + parameterMetadata.Name, + parameterMetadata.IsRequired, + parameterMetadata.DefaultValue); + } +} + +// TODO: Can this be obviated by AIFunctionParameter? +public class ParameterSchema(string name, bool isRequired = false, T? defaultValue = default) + : ParameterSchema(name, typeof(T), isRequired, defaultValue) +{ + public static ParameterSchema Create(Type type, string name, bool isRequired = false, object? defaultValue = default) + { + Type parameterSchemaType = typeof(ParameterSchema<>).MakeGenericType(type); + ParameterSchema? maybeResult = Activator.CreateInstance(parameterSchemaType, name, isRequired, defaultValue) as ParameterSchema; + return maybeResult!; + } +} + +public interface ITool +{ + string Name { get; } + string Description { get; } + + public IEnumerable Parameters { get; } + public Type ReturnType { get; } + + // TODO: State serialization + + // TODO: Can we somehow make this a ValueTask? + public Task ExecuteAsync(IEnumerable parameters, CancellationToken cancellationToken = default); + + public AIFunction AIFunction + { + get + { + return CallableTool.CreateAIFunction(this.Name, this.Description, this.ExecuteAsync); + } + } +} + +public static class TypeExtensions +{ + private static ISet TaskTypes = new HashSet([typeof(Task<>), typeof(ValueTask<>)]); + + public static Type UnwrapReturnIfAsync(this Type type) + { + if (type.IsGenericType && TaskTypes.Contains(type.GetGenericTypeDefinition())) + { + return type.GetGenericArguments()[0]; + } + else if (type == typeof(Task) || type == typeof(ValueTask)) + { + return typeof(void); + } + else + { + return type; + } + } +} + +public class AIFunctionTool(AIFunction aiFunction) : ITool +{ + public AIFunction AIFunction { get; } = aiFunction; + + public string Name => this.AIFunction.Metadata.Name; + + public string Description => this.AIFunction.Metadata.Description; + + public IEnumerable Parameters => from rawParameter in this.AIFunction.Metadata.Parameters + select (ParameterSchema)rawParameter; + + // TODO: Deal with missing return types + public Type ReturnType => this.AIFunction.Metadata.ReturnParameter.ParameterType!; + + public Task ExecuteAsync(IEnumerable parameters, CancellationToken cancellationToken = default) + => this.ExecuteAsync(parameters, cancellationToken); +} + +// TODO: Should we use M.E.Ai.AIFunction? +public class CallableTool(string name, string description, Delegate callable) + : AIFunctionTool(CreateAIFunction(name, description, callable)) +{ + internal static AIFunction CreateAIFunction(string name, string description, Delegate callable) + { + MethodInfo methodInfo = callable.Method; + + IEnumerable parameters = + from parameterInfo in methodInfo.GetParameters() + select parameterInfo.ToAIFunctionMetadata(); + + AIFunctionReturnParameterMetadata returnParameter = methodInfo.ReturnParameter.ToAIFunctionReturnMetadata(); + + AIFunctionFactoryCreateOptions createOptions = new() + { + Name = name, + Description = description, + Parameters = parameters.ToList(), + ReturnParameter = returnParameter, + // SerializerOptions = TODO: How do we maintain consistency with Python? + }; + + return AIFunctionFactory.Create(callable, createOptions); + } +} diff --git a/dotnet/src/Microsoft.AutoGen/AgentChat/Agents/AssistantAgent.cs b/dotnet/src/Microsoft.AutoGen/AgentChat/Agents/AssistantAgent.cs new file mode 100644 index 000000000000..8b07657017a8 --- /dev/null +++ b/dotnet/src/Microsoft.AutoGen/AgentChat/Agents/AssistantAgent.cs @@ -0,0 +1,249 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// AssistantAgent.cs + +using System.Diagnostics; +using System.Runtime.CompilerServices; +using Microsoft.AutoGen.AgentChat.Abstractions; +using Microsoft.Extensions.AI; + +using ChatMessage = Microsoft.AutoGen.AgentChat.Abstractions.ChatMessage; +using CompletionChatMessage = Microsoft.Extensions.AI.ChatMessage; + +namespace Microsoft.AutoGen.AgentChat.Agents; + +public class AssistantAgent : ChatAgentBase +{ + private const string DefaultDescription = "An agent that provides assistance with ability to use tools."; + private const string DefaultSystemPrompt = "You are a helpful AI assistant. Solve tasks using your tools. Reply with 'TERMINATE' when the task has been completed."; + + private IChatClient modelClient; + + private Dictionary tools; + + private CompletionChatMessage systemMessage; + private List modelContext; + + private HashSet handoffs; + + private static Dictionary PrepareTools(IEnumerable? tools, IEnumerable? handoffs, out HashSet handoffNames) + { + Dictionary result = new Dictionary(); + handoffNames = []; + + foreach (ITool tool in tools ?? []) + { + if (result.ContainsKey(tool.Name)) + { + throw new ArgumentException($"Tool names must be unique. Duplicate tool name: {tool.Name}"); + } + + result[tool.Name] = tool; + } + + foreach (Handoff handoff in handoffs ?? []) + { + if (handoffNames.Contains(handoff.Name)) + { + throw new ArgumentException($"Handoff names must be unique. Duplicate handoff name: {handoff.Name}"); + } + + if (result.ContainsKey(handoff.Name)) + { + throw new ArgumentException($"Handoff names must be unique from tool names. Duplicate handoff name: {handoff.Name}"); + } + + result[handoff.Name] = handoff.HandoffTool; + handoffNames.Add(handoff.Name); + } + + return result; + } + + private static Dictionary PrepareHandoffs(IEnumerable? handoffs, HashSet uniqueToolNames, out List handoffTools) + { + if (handoffs == null) + { + handoffTools = []; + return new Dictionary(); + } + + HashSet uniqueNames = new HashSet(from handoff in handoffs select handoff.Name); + if (uniqueNames.Count != handoffs.Count()) + { + throw new ArgumentException($"Handoff names must be unique."); + } + + if (uniqueNames.Overlaps(uniqueToolNames)) + { + throw new ArgumentException("Handoff names must be unique from tool names."); + } + + handoffTools = (from handoff in handoffs select handoff.HandoffTool).ToList(); + return handoffs.ToDictionary(handoff => handoff.Name); + } + + public AssistantAgent(string name, + IChatClient modelClient, + string description = DefaultDescription, + string systemPrompt = DefaultSystemPrompt, + IEnumerable? tools = null, + IEnumerable? handoffs = null) + : base(name, description) + { + this.modelClient = modelClient; + this.systemMessage = new CompletionChatMessage(ChatRole.System, systemPrompt); + this.modelContext = [this.systemMessage]; + + this.tools = AssistantAgent.PrepareTools(tools, handoffs, out this.handoffs); + } + + public override IEnumerable ProducedMessageTypes => + this.handoffs.Any() ? [typeof(TextMessage), typeof(HandoffMessage)] + : [typeof(TextMessage)]; + + public override async ValueTask HandleAsync(IEnumerable item, CancellationToken cancellationToken) + { + await foreach (ChatStreamFrame frame in this.StreamAsync(item, cancellationToken)) + { + if (frame.Type == ChatStreamFrame.FrameType.Response) + { + return frame.Response!; + } + } + + throw new InvalidOperationException("The stream should have returned a final result."); + } + + private async Task InvokeToolAsync(FunctionCallContent functionCall, CancellationToken cancellationToken) + { + if (this.tools.Count == 0) + { + throw new InvalidOperationException("No tools available."); + } + + ITool? targetTool = this.tools.GetValueOrDefault(functionCall.Name) + ?? throw new ArgumentException($"Unknown tool: {functionCall.Name}"); + + List parameters = new List(); + if (functionCall.Arguments != null) + { + foreach (var parameter in targetTool.Parameters) + { + if (!functionCall.Arguments!.TryGetValue(parameter.Name, out object? o)) + { + if (parameter.IsRequired) + { + throw new ArgumentException($"Missing required parameter: {parameter.Name}"); + } + else + { + o = parameter.DefaultValue; + } + } + + parameters.Add(o); + } + } + + try + { + // TODO: Nullability constraint on the tool execution is bad + object callResult = await targetTool.ExecuteAsync((IEnumerable)parameters, cancellationToken); + + return new FunctionResultContent(functionCall.CallId, functionCall.Name, callResult); + } + catch (Exception e) + { + return new FunctionResultContent(functionCall.CallId, functionCall.Name, $"Error: {e}"); + } + } + + public override async IAsyncEnumerable StreamAsync(IEnumerable item, [EnumeratorCancellation] CancellationToken cancellationToken) + { + // TODO: feed the right Roles into the call + this.modelContext.AddRange(from message in item select message.ToCompletionClientMessage(ChatRole.User)); + + List innerMessages = []; + + ChatOptions options = new() + { + ToolMode = ChatToolMode.Auto, + + // TODO: We should probably just use the M.E.Ai.Abtraction types directly + // TODO: Should we cache this List? Also, why is this a list, rather than an Enumerable? + Tools = (from tool in this.tools.Values select (AITool)tool.AIFunction).ToList() + }; + + ChatCompletion completion = await this.modelClient.CompleteAsync(this.modelContext, options, cancellationToken); + + // TODO: Flatten traverses the message contents already; we are about to do it again: There's a better way. + this.modelContext.Add(completion.Message.Flatten()); + + // TODO: I am not sure this is doing what we want it to be doing: This checks that everything inside of the + // completion is a FunctionCall. + + while (completion.Message.Contents.All(content => content is FunctionCallContent)) + { + ToolCallMessage toolCall = new() { Source = this.Name }; + + // TODO: A nicer API for this + IEnumerable calls = completion.Message.Contents.Cast(); + toolCall.Content.AddRange(calls); + + yield return new ChatStreamFrame { Type = ChatStreamFrame.FrameType.InternalMessage, InternalMessage = toolCall }; + + List> toolCallTasks = (from call in calls + select InvokeToolAsync(call, cancellationToken)) + .ToList(); + + // TODO: Enable streaming the results as they come in, rather than in a batch? + FunctionResultContent[] taskResult = await Task.WhenAll(toolCallTasks); + ToolCallResultMessage toolCallResult = new() { Source = this.Name }; + toolCallResult.Content.AddRange(taskResult); + + this.modelContext.Add(toolCallResult.ToCompletionClientMessage()); + + yield return new ChatStreamFrame { Type = ChatStreamFrame.FrameType.InternalMessage, InternalMessage = toolCallResult }; + + List handoffs = (from FunctionResultContent result in taskResult + where this.handoffs.Contains(result.Name) + select (Handoff)this.tools[result.Name]) + .ToList(); + + if (handoffs.Count > 1) + { + throw new InvalidOperationException($"Multiple handoffs detected: { String.Join(", ", from handoff in handoffs select handoff.Name) }"); + } + else if (handoffs.Count == 1) + { + yield return new ChatStreamFrame + { + Type = ChatStreamFrame.FrameType.Response, + Response = new Response + { + Message = new HandoffMessage + { + Source = this.Name, + Target = handoffs[0].Target, + Content = handoffs[0].Message + } + } + }; + yield break; + } + + completion = await this.modelClient.CompleteAsync(this.modelContext, options, cancellationToken); + this.modelContext.Add(completion.Message.Flatten()); + } + + // We expect the completion to be a single TextContent at this point + Debug.Assert(completion.Message.Contents.Count != 1 || !(completion.Message.Contents[0] is TextContent)); + yield return new ChatStreamFrame { Type = ChatStreamFrame.FrameType.Response, Response = new Response { Message = new TextMessage { Source = this.Name, Content = completion.Message.Text! } } }; + } + + public override ValueTask ResetAsync(CancellationToken cancellationToken) + { + this.modelContext = [this.systemMessage]; + return ValueTask.CompletedTask; + } +} diff --git a/dotnet/src/Microsoft.AutoGen/AgentChat/Agents/ChatAgentBase.cs b/dotnet/src/Microsoft.AutoGen/AgentChat/Agents/ChatAgentBase.cs new file mode 100644 index 000000000000..5af9a9a9ff63 --- /dev/null +++ b/dotnet/src/Microsoft.AutoGen/AgentChat/Agents/ChatAgentBase.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ChatAgentBase.cs + +using System.Runtime.CompilerServices; +using Microsoft.AutoGen.AgentChat.Abstractions; + +namespace Microsoft.AutoGen.AgentChat.Agents; + +public abstract class ChatAgentBase : IChatAgent +{ + public ChatAgentBase(string name, string description) + { + Name = new AgentName(name); + Description = description; + } + + public AgentName Name { get; } + public string Description { get; } + + public virtual async IAsyncEnumerable StreamAsync(IEnumerable item, [EnumeratorCancellation] CancellationToken cancellationToken) + { + Response response = await (this).HandleAsync(item, cancellationToken); + if (response.InnerMessages != null) + { + foreach (var message in response.InnerMessages) + { + // It would be really nice to have type unions in .NET; need to think about how to make this interface nicer. + yield return new ChatStreamFrame { Type = ChatStreamFrame.FrameType.InternalMessage, InternalMessage = message }; + } + } + + yield return new ChatStreamFrame { Type = ChatStreamFrame.FrameType.Response, Response = response }; + } + + public abstract IEnumerable ProducedMessageTypes { get; } + + public abstract ValueTask HandleAsync(IEnumerable item, CancellationToken cancellationToken); + public abstract ValueTask ResetAsync(CancellationToken cancellationToken); +} diff --git a/dotnet/src/Microsoft.AutoGen/AgentChat/Agents/CodeExecutorAgent.cs b/dotnet/src/Microsoft.AutoGen/AgentChat/Agents/CodeExecutorAgent.cs new file mode 100644 index 000000000000..66ec03ac29b8 --- /dev/null +++ b/dotnet/src/Microsoft.AutoGen/AgentChat/Agents/CodeExecutorAgent.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// CodeExecutorAgent.cs + +using System.Text.RegularExpressions; +using Microsoft.AutoGen.Abstractions; +using Microsoft.AutoGen.AgentChat.Abstractions; + +using TextMessage = Microsoft.AutoGen.AgentChat.Abstractions.TextMessage; + +namespace Microsoft.AutoGen.AgentChat.Agents; + +// TODO: Should this live in one of the Core packages (similar to .components in python?) +public static partial class MarkdownExtensions +{ + private static readonly Regex codeBlockPattern = new Regex(@"```(?:\s*(?[\w\+\-]+))?\r?\n(?[\s\S]*)```", RegexOptions.Compiled | RegexOptions.Multiline); + public static IEnumerable ExtractCodeBlocks(this string markdownText) + { + return codeBlockPattern.Matches(markdownText) + .Select((match) => + new CodeBlock + { + Code = match.Groups["code"].Value.TrimEnd(), + Language = match.Groups["language"].Value.Trim() + }); + } +} + +public class CodeExecutorAgent : ChatAgentBase +{ + private const string DefaultDescription = "A computer terminal that performs no other action than running scripts (provided to it quoted in ```> code blocks)."; + public CodeExecutorAgent(string name, ICodeExecutor codeExecutor, string description = DefaultDescription) : base(name, description) + { + CodeExecutor = codeExecutor; + } + + public ICodeExecutor CodeExecutor { get; } + + public override IEnumerable ProducedMessageTypes => [ typeof(TextMessage) ]; + + public override async ValueTask HandleAsync(IEnumerable messages, CancellationToken cancellationToken) + { + var codeBlocks = messages.OfType() + .SelectMany((textMessage) => textMessage.Content.ExtractCodeBlocks()); + + var result = "No code blocks found."; + if (codeBlocks.Any()) + { + var codeResult = await CodeExecutor.ExecuteCodeBlocksAsync(codeBlocks, cancellationToken); + result = codeResult.Output; + } + + return new Response { Message = new TextMessage { Content = "No code blocks found.", Source = Name } }; + } + + public override ValueTask ResetAsync(CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } +} diff --git a/dotnet/src/Microsoft.AutoGen/AgentChat/Agents/CodingAssistantAgent.cs b/dotnet/src/Microsoft.AutoGen/AgentChat/Agents/CodingAssistantAgent.cs new file mode 100644 index 000000000000..30ff03835ba6 --- /dev/null +++ b/dotnet/src/Microsoft.AutoGen/AgentChat/Agents/CodingAssistantAgent.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// CodingAssistantAgent.cs + +using Microsoft.AutoGen.AgentChat.Abstractions; +using Microsoft.Extensions.AI; + +using ChatMessage = Microsoft.AutoGen.AgentChat.Abstractions.ChatMessage; +using CompletionChatMessage = Microsoft.Extensions.AI.ChatMessage; + +namespace Microsoft.AutoGen.AgentChat.Agents; + +// TODO: Replatfrom this on top of AssistantAgent +public class CodingAssistantAgent : ChatAgentBase +{ + // TODO: How do we make this be more pluggable depending on what ICodeExecutor can expect to be able to code? + private const string DefaultDescription = "A helpful and general-purpose AI assistant that has strong language skills, Python skills, and C# .NET skills."; + private const string DefaultPrompt = @"You are a helpful AI assistant. +Solve tasks using your coding and language skills. +In the following cases, suggest python code (in a python coding block) or shell script (in a sh coding block) for the user to execute. + 1. When you need to collect info, use the code to output the info you need, for example, browse or search the web, download/read a file, print the content of a webpage or a file, get the current date/time, check the operating system. After sufficient info is printed and the task is ready to be solved based on your language skill, you can solve the task by yourself. + 2. When you need to perform some task with code, use the code to perform the task and output the result. Finish the task smartly. +Solve the task step by step if you need to. If a plan is not provided, explain your plan first. Be clear which step uses code, and which step uses your language skill. +When using code, you must indicate the script type in the code block. The user cannot provide any other feedback or perform any other action beyond executing the code you suggest. The user can't modify your code. So do not suggest incomplete code which requires users to modify. Don't use a code block if it's not intended to be executed by the user. +If you want the user to save the code in a file before executing it, put # filename: inside the code block as the first line. Don't include multiple code blocks in one response. Do not ask users to copy and paste the result. Instead, use 'print' function for the output when relevant. Check the execution result returned by the user. +If the result indicates there is an error, fix the error and output the code again. Suggest the full code instead of partial code or code changes. If the error can't be fixed or if the task is not solved even after the code is executed successfully, analyze the problem, revisit your assumption, collect additional info you need, and think of a different approach to try. +When you find an answer, verify the answer carefully. Include verifiable evidence in your response if possible. +Reply ""TERMINATE"" in the end when code has been executed and task is complete."; + + //private string prompt; + private IChatClient completionClient; + + private CompletionChatMessage systemMessage; + private List modelContext; + + public CodingAssistantAgent(string name, IChatClient completionClient, string description = DefaultDescription, string prompt = DefaultPrompt) : base(name, description) + { + this.completionClient = completionClient; + //this.prompt = prompt; + + systemMessage = new CompletionChatMessage(ChatRole.System, prompt); + modelContext = new List(); + + // TODO: More coherent defaults for ChatOptions? Is this even needed? + ChatOptions = null; + } + + public ChatOptions? ChatOptions { get; set; } + + public override IEnumerable ProducedMessageTypes => [typeof(TextMessage)]; + + public override async ValueTask HandleAsync(IEnumerable messages, CancellationToken cancellationToken) + { + foreach (var message in messages) + { + CompletionChatMessage completionMessage; + if (message is TextMessage textMessage) + { + completionMessage = new CompletionChatMessage(ChatRole.User, textMessage.Content); + } + else if (message is MultiModalMessage multiModalMessage) + { + // TODO: Microsoft.Abstractions.AI.MultiModalMessage is not implemented + completionMessage = new CompletionChatMessage(ChatRole.User, multiModalMessage); + } + else if (message is StopMessage stopMessage) + { + completionMessage = new CompletionChatMessage(ChatRole.User, stopMessage.Content); + } + else + { + throw new ArgumentException($"Unsupported message type: {message.GetType()}"); + } + + modelContext.Add(completionMessage); + } + + var llmMessages = new List(); + llmMessages.Add(systemMessage); + llmMessages.AddRange(modelContext); + + var completion = await completionClient.CompleteAsync(llmMessages, ChatOptions, cancellationToken); + + // TODO: Do a more reasonable thing here when there are multiple choices? + var choice = completion.Choices[0]; + var result = new MultiModalMessage() { Source = Name }; + + foreach (var item in choice.Contents) + { + result.Add(item); + } + + return new Response { Message = result }; + } + + public override ValueTask ResetAsync(CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } +} diff --git a/dotnet/src/Microsoft.AutoGen/AgentChat/Agents/SocietyOfMindAgent.cs b/dotnet/src/Microsoft.AutoGen/AgentChat/Agents/SocietyOfMindAgent.cs new file mode 100644 index 000000000000..192d0bd7202d --- /dev/null +++ b/dotnet/src/Microsoft.AutoGen/AgentChat/Agents/SocietyOfMindAgent.cs @@ -0,0 +1,360 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// SocietyOfMindAgent.cs + +using System.Runtime.CompilerServices; +using System.Text; +using Microsoft.AutoGen.AgentChat.Abstractions; +using Microsoft.Extensions.AI; + +using MEAI = Microsoft.Extensions.AI; + +using ChatMessage = Microsoft.AutoGen.AgentChat.Abstractions.ChatMessage; +namespace Microsoft.AutoGen.AgentChat.Agents; + +[InterpolatedStringHandler] +public class TemplateBuilder +{ + private readonly List segments = []; + private StringTemplate.LiteralRun? currentLiteralRun = new StringTemplate.LiteralRun(); + + public TemplateBuilder(int literalLength, int formattedCount) + { + } + + public void AppendLiteral(string literal) + { + (this.currentLiteralRun ??= new StringTemplate.LiteralRun()).Append(literal); + } + + public void AppendFormatted(T value, string? format = null) + { + if (value is StringTemplate.Slot slot) + { + if (this.currentLiteralRun != null) + { + this.segments.Add(this.currentLiteralRun); + this.currentLiteralRun = null; + } + + this.segments.Add(slot.BindFormat(format)); + } + else + { + this.AppendLiteral(string.Format($"{{0:{format}}}", value)); + } + } + + public StringBuilder BuildTo(StringBuilder target, IDictionary values) + { + if (this.currentLiteralRun != null) + { + this.segments.Add(this.currentLiteralRun); + this.currentLiteralRun = null; + } + + foreach (var segment in this.segments) + { + segment.BuildTo(target, values); + } + + return target; + } + + public string Build(IDictionary values) + { + return this.BuildTo(new StringBuilder(), values).ToString(); + } +} + +public class StringTemplate(TemplateBuilder builder) +{ + public record struct Slot(string Name) + { + internal SlotFormatter BindFormat(string? format) => new SlotFormatter(Name, format); + } + + internal sealed record class SlotFormatter(string Name, string? Format) + { + private string FormatInternal(T value) + { + // If value is null, but the format string is not, return an empty string. + if (value == null) + { + return string.Empty; + } + + return Format == null ? value.ToString() ?? string.Empty : string.Format(Format, value); + } + + private StringBuilder AppendInternal(StringBuilder target, T value) + { + return Format == null ? target.Append(value) : target.AppendFormat($"{{0:{Format}}}", value); + } + + public string FormatString(T value) => FormatInternal(value); + public string FormatString(IDictionary values) => FormatInternal(values[Name]); + + public StringBuilder AppendFormatted(StringBuilder target, T value) => AppendInternal(target, value); + public StringBuilder AppendFormatted(StringBuilder target, IDictionary values) => AppendInternal(target, values[Name]); + } + + internal sealed class LiteralRun + { + private readonly List literals = []; + + public void Append(string literal) + { + if (string.IsNullOrEmpty(literal)) + { + return; + } + + this.literals.Add(literal); + } + + public string Build(bool memoize = true) + { + switch (this.literals.Count) + { + case 0: + return string.Empty; + case 1: + return this.literals[0]; + default: + string result = string.Concat(this.literals); + + if (memoize) + { + this.literals.Clear(); + this.literals.Add(result); + } + + return result; + } + } + + public StringBuilder BuildTo(StringBuilder target) + { + switch (this.literals.Count) + { + case 0: + return target; + case 1: + return target.Append(this.literals[0]); + // TODO: Manually unroll small counts? + default: + return literals.Aggregate(target, (t, s) => t.Append(s)); + } + } + } + + internal struct Run + { + public bool IsLiteral { get; } + + public LiteralRun? Literal { get; } + public SlotFormatter? DynamicSlot { get; } + + public Run(LiteralRun literal) + { + this.IsLiteral = true; + this.Literal = literal; + this.DynamicSlot = null; + } + + public Run(SlotFormatter slot) + { + this.IsLiteral = false; + this.Literal = null; + this.DynamicSlot = slot; + } + + public static implicit operator Run(LiteralRun literal) => new Run(literal); + public static implicit operator Run(SlotFormatter slot) => new Run(slot); + + public StringBuilder BuildTo(StringBuilder target, IDictionary values) + { + if (this.IsLiteral) + { + return this.Literal!.BuildTo(target); + } + else + { + return this.DynamicSlot!.AppendFormatted(target, values); + } + } + } + + private TemplateBuilder templateBuilder = builder; + + public StringBuilder BuildTo(StringBuilder target, IDictionary values) + { + return this.templateBuilder.BuildTo(target, values); + } + + public string Build(IDictionary values) + { + return this.templateBuilder.Build(values); + } +} + +public class SocietyOfMindAgent : ChatAgentBase +{ + public static readonly StringTemplate.Slot TranscriptSlot = new StringTemplate.Slot("transcript"); + + public const string DefaultDescription = ""; + public readonly StringTemplate DefaultTaskPrompt = new($"{TranscriptSlot}\nContinue."); + public readonly StringTemplate DefaultResponsePrompt = new($"Here is a transcript of conversation so far:\n{TranscriptSlot}\n\\Provide a response to the original request."); + + private ITeam team; + private IChatClient chatClient; + + private StringTemplate taskPrompt; + private StringTemplate responsePrompt; + + public SocietyOfMindAgent(string name, ITeam team, IChatClient chatClient, string description = DefaultDescription, StringTemplate? taskPrompt = null, StringTemplate? responsePrompt = null) + : base(name, description) + { + this.team = team; + this.chatClient = chatClient; + + this.taskPrompt = taskPrompt ?? this.DefaultTaskPrompt; + this.responsePrompt = responsePrompt ?? this.DefaultResponsePrompt; + } + + private string FormatTask(StringBuilder transcript) + { + return this.taskPrompt.Build(new Dictionary { ["transcript"] = transcript }); + } + + private string FormatResponse(StringBuilder transcript) + { + return this.responsePrompt.Build(new Dictionary { ["transcript"] = transcript }); + } + + public override IEnumerable ProducedMessageTypes => []; + + private StringBuilder CreateTranscript(IEnumerable item) + { + StringBuilder transcript = new StringBuilder(); + + // TODO: It is unclear how to deal with tool use messages here (Python deals with duck typing better) + foreach (ChatMessage message in item.OfType()) + { + _ = message switch + { + TextMessage textMessage => transcript.AppendLine($"{message.Source}: {textMessage.Content}"), + StopMessage stopMessage => transcript.AppendLine($"{message.Source}: {stopMessage.Content}"), + HandoffMessage handoffMessage => transcript.AppendLine($"{message.Source}: {handoffMessage.Content}"), + + MultiModalMessage multiModalMessage => AppendMultiModalMessage(multiModalMessage), + _ => throw new InvalidOperationException($"Unexpected message type: {message} in {this.GetType().FullName}"), + }; + } + + return transcript; + + StringBuilder AppendMultiModalMessage(MultiModalMessage message) + { + foreach (MultiModalData part in message.Content) + { + transcript.Append($"{message.Source}: "); + + if (part.ContentType == MultiModalData.Type.String) + { + transcript.AppendLine(((TextContent)part.AIContent).Text); + } + else if (part.ContentType == MultiModalData.Type.Image) + { + transcript.AppendLine("[Image]"); + } + else + { + // Best efforts + transcript.AppendLine(part.AIContent.RawRepresentation?.ToString() ?? part.AIContent.ToString()); + } + } + + return transcript; + } + } + + private ChatStreamFrame ProduceResponse(string result) + { + Response response = new Response { Message = new TextMessage { Source = this.Name, Content = result } }; + return new ChatStreamFrame { Type = ChatStreamFrame.FrameType.Response, Response = response }; + } + + public override async ValueTask HandleAsync(IEnumerable item, CancellationToken cancellationToken) + { + // In the Python implementation AssistantAgent and SocietyOfMindAgent have different strategies for + // reducing on_messages to on_messages_stream. The former returns the first Response as the final + // result, while the latter takes the last + Response? response = null; + await foreach (ChatStreamFrame frame in this.StreamAsync(item, cancellationToken)) + { + if (frame.Type == ChatStreamFrame.FrameType.Response) + { + response = frame.Response; + } + }; + + return response ?? throw new InvalidOperationException("No response."); + } + + public override async IAsyncEnumerable StreamAsync(IEnumerable item, [EnumeratorCancellation] CancellationToken cancellationToken) + { + List delta = item.ToList(); + string taskSpecification = this.FormatTask(this.CreateTranscript(delta)); + + TaskResult? result = null; + List innerMessages = []; + + await foreach (TaskFrame frame in this.team.StreamAsync(taskSpecification, cancellationToken)) + { + if (frame.Type == TaskFrame.FrameType.Response) + { + result = frame.Response!; + //break; // Python does not break out on receiving a response, so last response wins + } + else // if (frame.Type == StreamingFrame.FrameType.InternalMessage) + { + yield return new ChatStreamFrame { Type = ChatStreamFrame.FrameType.InternalMessage, InternalMessage = frame.InternalMessage! }; + innerMessages.Add(frame.InternalMessage!); + } + } + + if (result == null) + { + throw new InvalidOperationException("The team did not produce a final response. Check the team's RunAsync method."); + } + + // The first message is the task message, so we need at least two messages + if (innerMessages.Count < 2) + { + yield return this.ProduceResponse("No response."); + } + else + { + string prompt = this.FormatResponse(this.CreateTranscript(innerMessages.Skip(1))); + + List messages = [new MEAI.ChatMessage(ChatRole.System, prompt)]; + + ChatCompletion completion = await this.chatClient.CompleteAsync(messages); + if (completion.Choices.Count < 1 || + completion.Choices[0].Text == null) + { + throw new InvalidOperationException("Could not produce final result."); + } + + yield return this.ProduceResponse(completion.Choices[0].Text!); + } + + await this.ResetAsync(cancellationToken); + } + + public override ValueTask ResetAsync(CancellationToken cancellationToken) + { + return this.team.ResetAsync(cancellationToken); + } +} diff --git a/dotnet/src/Microsoft.AutoGen/AgentChat/Agents/ToolUseAssistantAgent.cs b/dotnet/src/Microsoft.AutoGen/AgentChat/Agents/ToolUseAssistantAgent.cs new file mode 100644 index 000000000000..5b6d9d1e152b --- /dev/null +++ b/dotnet/src/Microsoft.AutoGen/AgentChat/Agents/ToolUseAssistantAgent.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ToolUseAssistantAgent.cs + +using Microsoft.AutoGen.AgentChat.Abstractions; +using Microsoft.Extensions.AI; + +namespace Microsoft.AutoGen.AgentChat.Agents; +internal sealed class ToolUseAssistantAgent : AssistantAgent +{ + private const string DefaultDescription = "An agent that provides assistance with ability to use tools."; + private const string DefaultSystemPrompt = "You are a helpful AI assistant. Solve tasks using your tools. Reply with 'TERMINATE' when the task has been completed."; + + [Obsolete("ToolUseAssistantAgent is deprecated. Use AssistantAgent instead.")] + public ToolUseAssistantAgent(string name, IChatClient modelClient, IEnumerable registeredTools, string description = DefaultDescription, string systemPrompt = DefaultSystemPrompt) : base(name, modelClient, description, systemPrompt, tools: registeredTools) + { + } +} diff --git a/dotnet/src/Microsoft.AutoGen/AgentChat/Agents/UserProxyAgent.cs b/dotnet/src/Microsoft.AutoGen/AgentChat/Agents/UserProxyAgent.cs new file mode 100644 index 000000000000..45c8e032561f --- /dev/null +++ b/dotnet/src/Microsoft.AutoGen/AgentChat/Agents/UserProxyAgent.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// UserProxyAgent.cs + +using Microsoft.AutoGen.AgentChat.Abstractions; + +using UserInputFn = System.Func; +using UserInputAsyncFn = System.Func>; + +namespace Microsoft.AutoGen.AgentChat.Agents; + +internal static class UserProxyExtensions +{ + public static UserInputAsyncFn ToAsync(this UserInputFn? fn) + { + return fn != null ? async (prompt) => fn(prompt) : StandardInput; + } + + public static async ValueTask StandardInput(string prompt) + { + await Console.Out.WriteAsync(prompt); + return await Console.In.ReadLineAsync() ?? string.Empty; + } + + public static HandoffMessage? GetLatestHandoff(this T item) where T : IEnumerable + { + return item.OfType().LastOrDefault(); + } +} + +public class UserProxyAgent : ChatAgentBase +{ + public override IEnumerable ProducedMessageTypes => [typeof(TextMessage), typeof(HandoffMessage)]; + private UserInputAsyncFn userInputFn; + + public const string DefaultDescription = "A human user"; + public UserProxyAgent(string name, string description = DefaultDescription, UserInputAsyncFn? userInputFn = null) + : base(name, description) + { + this.userInputFn = userInputFn ?? UserProxyExtensions.StandardInput; + } + + //[MethodImpl.AggressiveInlining] + private Response FormatResponse(string result, HandoffMessage? handoff) + { + ChatMessage responseMessage = + handoff == null ? + new TextMessage { Content = result, Source = this.Name } : + new HandoffMessage { Content = result, Source = this.Name, Target = handoff.Source }; + + return new Response { Message = responseMessage }; + } + + public override async ValueTask HandleAsync(IEnumerable item, CancellationToken cancellationToken) + { + try + { + HandoffMessage? handoff = item.GetLatestHandoff(); + string prompt = handoff != null ? + $"Handoff received from {handoff.Source}. Enter your response: " : + "Enter your response: "; + + string response = await this.userInputFn(prompt); + return this.FormatResponse(response, handoff); + } + catch (TaskCanceledException) + { + throw; + } + catch (Exception ex) + { + throw new IOException($"Failed to get user input: {ex.Message}", ex); + } + } + + public override ValueTask ResetAsync(CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } +} diff --git a/dotnet/src/Microsoft.AutoGen/AgentChat/GroupChat/AgentChatBinder.cs b/dotnet/src/Microsoft.AutoGen/AgentChat/GroupChat/AgentChatBinder.cs new file mode 100644 index 000000000000..8cdbad61980e --- /dev/null +++ b/dotnet/src/Microsoft.AutoGen/AgentChat/GroupChat/AgentChatBinder.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// AgentChatBinder.cs + +using Microsoft.AutoGen.Abstractions; +using Microsoft.AutoGen.AgentChat.Abstractions; +using Microsoft.Azure.Cosmos.Core; + +namespace Microsoft.AutoGen.AgentChat.GroupChat; + +public struct AgentChatConfig(IChatAgent chatAgent, string parentTopic, string outputTopic) +{ + public string ParentTopic { get; } = parentTopic; + public string OutputTopic { get; } = outputTopic; + + public IChatAgent ChatAgent { get; } = chatAgent; + + public string Name => this.ChatAgent.Name; +} + +public class AgentChatBinder(string groupChatTopic, string outputTopic, CancellationToken cancel = default) +{ + private readonly Dictionary agentConfigs = new Dictionary(); + + public string GroupChatTopic { get; } = groupChatTopic; + public string OutputTopic { get; } = outputTopic; + + public ITerminationCondition? TerminationCondition { get; set; } + + public AgentChatConfig this[string name] + { + get => this.agentConfigs[name]; + set => this.agentConfigs[name] = value; + } + + public AsyncQueue OutputQueue { get; } = new AsyncQueue(cancel); + public string? StopReason { get; set; } + + internal void SubscribeGroup(IAgentBase agent) + { + _ = agent.Subscribe(this.GroupChatTopic); + } + + internal void SubscribeOutput(OutputCollectorAgent outputCollector) + { + _ = outputCollector.Subscribe(this.OutputTopic); + } + + public IEnumerable ParticipantConfigs => this.agentConfigs.Values; + public List? MessageThread { get; set; } + + public IEnumerable ParticipantTopics => this.ParticipantConfigs.Select(p => p.Name); + public IEnumerable ParticipantDescriptions => this.ParticipantConfigs.Select(p => p.ChatAgent.Description); +} diff --git a/dotnet/src/Microsoft.AutoGen/AgentChat/GroupChat/ChatAgentContainer.cs b/dotnet/src/Microsoft.AutoGen/AgentChat/GroupChat/ChatAgentContainer.cs new file mode 100644 index 000000000000..229a96c9bb56 --- /dev/null +++ b/dotnet/src/Microsoft.AutoGen/AgentChat/GroupChat/ChatAgentContainer.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ChatAgentContainer.cs + +using Microsoft.AutoGen.Abstractions; +using Microsoft.AutoGen.AgentChat.Abstractions; +using Microsoft.AutoGen.Agents; + +namespace Microsoft.AutoGen.AgentChat.GroupChat; + +internal sealed class ChatAgentContainer : SequentialRoutedAgent, + IHandleEx, + IHandleEx, + IHandleEx, + IHandleEx, + IHandleDefault +{ + private readonly string parentTopic; + private readonly string outputTopic; + private readonly IChatAgent agent; + private readonly List messageBuffer; + + public ChatAgentContainer(IAgentRuntime agentContext, EventTypes eventTypes, AgentChatBinder configurator) + : base(agentContext, eventTypes) + { + AgentChatConfig agentConfig = configurator[agentContext.AgentId.Key]; + + this.agent = agentConfig.ChatAgent; + this.parentTopic = agentConfig.ParentTopic; + this.outputTopic = agentConfig.OutputTopic; + + configurator.SubscribeGroup(this); + + this.messageBuffer = new List(); + } + + public ValueTask HandleAsync(GroupChatStart item, CancellationToken cancellationToken) + { + if (item.Message != null) + { + this.messageBuffer.Add(item.Message); + } + + return ValueTask.CompletedTask; + } + + public ValueTask HandleAsync(GroupChatAgentResponse item, CancellationToken cancellationToken) + { + this.messageBuffer.Add(item.AgentResponse.Message); + return ValueTask.CompletedTask; + } + + public ValueTask HandleAsync(GroupChatReset item, CancellationToken cancellationToken) + { + this.messageBuffer.Clear(); + + return this.agent.ResetAsync(cancellationToken); + } + + public async ValueTask HandleAsync(GroupChatRequestPublish item, CancellationToken cancellationToken) + { + Response? response = null; + + // TODO: Is there a better abstraction here than IAsyncEnumerable? Though the akwardness mainly comes from + // the lack of real type unions in C#, which is why we need to create the StreamingFrame type in the first + // place. + await foreach (ChatStreamFrame frame in this.agent.StreamAsync(this.messageBuffer, cancellationToken)) + { + // TODO: call publish message + switch (frame.Type) + { + case ChatStreamFrame.FrameType.Response: + await this.PublishMessageAsync(new GroupChatMessage { Message = frame.Response!.Message }, this.outputTopic); + response = frame.Response; + break; + case ChatStreamFrame.FrameType.InternalMessage: + await this.PublishMessageAsync(new GroupChatMessage { Message = frame.InternalMessage! }, this.outputTopic); + break; + } + } + + if (response == null) + { + throw new InvalidOperationException("The agent did not produce a final response. Check the agent's on_messages_stream method."); + } + + this.messageBuffer.Clear(); + await this.PublishMessageAsync(new GroupChatAgentResponse { AgentResponse = response }, this.parentTopic); + } + + ValueTask IHandleEx.HandleAsync(object item, CancellationToken cancellationToken) + { + throw new InvalidOperationException($"Unhandled message in agent container: {item.GetType()}"); + } +} diff --git a/dotnet/src/Microsoft.AutoGen/AgentChat/GroupChat/Events.cs b/dotnet/src/Microsoft.AutoGen/AgentChat/GroupChat/Events.cs new file mode 100644 index 000000000000..0dfb77711c37 --- /dev/null +++ b/dotnet/src/Microsoft.AutoGen/AgentChat/GroupChat/Events.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Events.cs + +//using Microsoft.AutoGen.Abstractions; +using Microsoft.AutoGen.AgentChat.Abstractions; + +namespace Microsoft.AutoGen.AgentChat.GroupChat; + +public class GroupChatEventBase : ITodoMakeProto +{ +} + +/// +/// A request to start a group chat. +/// +public class GroupChatStart : GroupChatEventBase +{ + /// + /// The user message that started the group chat. + /// + public ChatMessage? Message { get; set; } +} + +public class GroupChatAgentResponse : GroupChatEventBase +{ + public required Response AgentResponse { get; set; } +} + +public class GroupChatRequestPublish : GroupChatEventBase +{ +} + +public class GroupChatMessage : GroupChatEventBase +{ + public required AgentMessage Message { get; set; } +} + +public class GroupChatTermination : GroupChatEventBase +{ + public required StopMessage Message { get; set; } +} + +public class GroupChatReset : GroupChatEventBase +{ +} + +///// +///// +///// +///// +///// Corresponds to Python-side `GroupChatPublicEvent` class, defined in `src/Microsoft.AutoGen/AgentChat/Teams/Events.py`. +///// +//public class GroupChatPublicEvent +//{ +// public ChatMessage AgentMessage { get; set; } + +// public AgentId? Source { get; set; } + +// public IDictionary ModelConfig { get; } + +// public GroupChatPublicEvent(ChatMessage agentMessage, AgentId? source, IDictionary modelConfig) +// { +// AgentMessage = agentMessage; +// Source = source; +// ModelConfig = modelConfig; +// } +//} + diff --git a/dotnet/src/Microsoft.AutoGen/AgentChat/GroupChat/GroupChatBase.cs b/dotnet/src/Microsoft.AutoGen/AgentChat/GroupChat/GroupChatBase.cs new file mode 100644 index 000000000000..fc5964e86665 --- /dev/null +++ b/dotnet/src/Microsoft.AutoGen/AgentChat/GroupChat/GroupChatBase.cs @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// GroupChatBase.cs + +using System.Runtime.CompilerServices; +using Microsoft.AspNetCore.Builder; +using Microsoft.AutoGen.AgentChat.Abstractions; +using Microsoft.AutoGen.Agents; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using TextMessage = Microsoft.AutoGen.AgentChat.Abstractions.TextMessage; + +namespace Microsoft.AutoGen.AgentChat.GroupChat; + +public abstract class GroupChatBase : ITeam where TManager : GroupChatManagerBase +{ + private readonly List participants; + private readonly List messageThread; + private readonly ITerminationCondition? terminationCondition; + + public GroupChatBase(List participants, ITerminationCondition? terminationCondition = null) + { + this.participants = participants; + this.messageThread = new List(); + this.terminationCondition = terminationCondition; + + this.TeamId = Guid.NewGuid(); + } + + public Guid TeamId + { + get; + private set; + } + + // TODO: Turn this into an IDisposable-based utility + private int running; // = 0 + private bool EnsureSingleRun() + { + return Interlocked.CompareExchange(ref running, 1, 0) == 0; + } + + private void EndRun() + { + this.running = 0; + } + + public async IAsyncEnumerable StreamAsync(string task, [EnumeratorCancellation] CancellationToken cancellationToken) + { + if (String.IsNullOrEmpty(task)) + { + throw new ArgumentNullException(nameof(task)); + } + + if (!this.EnsureSingleRun()) + { + throw new InvalidOperationException("The task is already running."); + } + + WebApplicationBuilder hostBuilder = WebApplication.CreateBuilder(); + + // Inject relevant services + + // TODO: Where do these come from? + const string groupTopicType = "group_topic"; + const string outputTopicType = "output_topic"; + const string groupChatManagerTopicType = "group_chat_manager"; + const string collectorAgentType = "collect_output_messages"; + + Dictionary agentMap = new Dictionary(); + AgentChatBinder binder = new AgentChatBinder(groupTopicType, outputTopicType, cancellationToken); + + foreach (var participant in participants) + { + binder[participant.Name] = new AgentChatConfig(participant, groupTopicType, outputTopicType); + agentMap[participant.Name] = typeof(ChatAgentContainer); // We always host inside of ChatAgentContainer + } + + agentMap[groupChatManagerTopicType] = typeof(TManager); + agentMap[collectorAgentType] = typeof(OutputCollectorAgent); + + hostBuilder.Services.AddSingleton(binder); + + AgentTypes agentTypes = new AgentTypes(agentMap); + + await AgentsApp.StartAsync(hostBuilder, agentTypes, local: true); + + // TODO: Send this on + GroupChatStart taskStart = new() + { + Message = new TextMessage + { + Content = task, + Source = "user" + } + }; + + // TODO: Turn this into a less verbose helper call + Task shutdownTask = AgentsApp.Host.WaitForShutdownAsync(cancellationToken); + _ = shutdownTask.ContinueWith((t) => + { + binder.OutputQueue.TryEnqueue(null); + }, cancellationToken, TaskContinuationOptions.AttachedToParent, TaskScheduler.Current); + + try + { + // TODO: Protos + GroupChatStart taskMessage = new GroupChatStart + { + Message = new TextMessage + { + Content = task, + Source = "user" + } + }; + + // TODO: Convert events to Protos so they can participate in the PublishMessageAsync contracts + // Alternatively, don't force the events that we publish to be Protos (similar to Python?) + //await AgentsApp.PublishMessageAsync(groupChatManagerTopicType, taskMessage.ToCloudEvt()); + + List outputMessages = []; + while (true) + { + AgentMessage? chatMessage = await binder.OutputQueue.DequeueAsync(); + // any of: + // * the queue was disposed, + // * we sent a null, + // * cancellation was requested + if (chatMessage == null) + { + break; + } + + outputMessages.Add(chatMessage); + yield return new TaskFrame(chatMessage); + } + + TaskResult result = new TaskResult(outputMessages) + { + StopReason = binder.StopReason + }; + + yield return new TaskFrame(result); + } + finally + { + await shutdownTask; + + + + this.EndRun(); + } + } + + public ValueTask ResetAsync(CancellationToken cancel) + { + throw new NotImplementedException(); + } +} diff --git a/dotnet/src/Microsoft.AutoGen/AgentChat/GroupChat/GroupChatManagerBase.cs b/dotnet/src/Microsoft.AutoGen/AgentChat/GroupChat/GroupChatManagerBase.cs new file mode 100644 index 000000000000..53cb8a450d24 --- /dev/null +++ b/dotnet/src/Microsoft.AutoGen/AgentChat/GroupChat/GroupChatManagerBase.cs @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// GroupChatManagerBase.cs + +using Microsoft.AutoGen.Abstractions; +using Microsoft.AutoGen.AgentChat.Abstractions; +using Microsoft.AutoGen.Agents; + +namespace Microsoft.AutoGen.AgentChat.GroupChat; + +public abstract class GroupChatManagerBase : SequentialRoutedAgent, + IHandleEx, + IHandleEx, + IHandleDefault +{ + private string groupChatTopic; + private string outputTopic; + private List participantTopics; + private List participantDescriptions; + private List messageThread; + private ITerminationCondition? terminationCondition; + + // It is kind of annoying that all child classes need to be aware of all of these objects to go up the stack + // TODO: Should we replace all of these with IServiceCollection? + public GroupChatManagerBase(IAgentRuntime context, EventTypes eventTypes, AgentChatBinder configurator) : base(context, eventTypes) + { + this.groupChatTopic = configurator.GroupChatTopic; + this.outputTopic = configurator.OutputTopic; + + if (configurator.ParticipantTopics.Count() != configurator.ParticipantDescriptions.Count()) + { + throw new ArgumentException("participantTopics and participantDescriptions must have the same number of elements"); + } + + HashSet uniqueTopics = configurator.ParticipantTopics.ToHashSet(); + if (uniqueTopics.Count != configurator.ParticipantTopics.Count()) + { + throw new ArgumentException("The participant topic ids must be unique."); + } + + if (uniqueTopics.Contains(groupChatTopic)) + { + throw new ArgumentException("The group topic id must not be in the participant topic ids."); + } + + // TODO: Should we listify it early? Technically, Dictionary<> does not guarantee order + this.participantTopics = configurator.ParticipantTopics.ToList(); + this.participantDescriptions = configurator.ParticipantDescriptions.ToList(); + + this.messageThread = configurator.MessageThread ?? new List(); + this.terminationCondition = configurator.TerminationCondition; + + configurator.SubscribeGroup(this); + } + + protected virtual async ValueTask ValidateGroupState(ChatMessage? message) + { + } + + public abstract ValueTask SelectSpeakerAsync(List thread); + + public async ValueTask HandleAsync(GroupChatStart item, CancellationToken cancellationToken) + { + await this.PublishMessageAsync(item, this.groupChatTopic); + + if (this.terminationCondition != null && this.terminationCondition.IsTerminated) + { + StopMessage earlyStop = new StopMessage + { + Content = "The chat has already terminated", + Source = "GroupChatManager" + }; + + await this.PublishMessageAsync(new GroupChatTermination { Message = earlyStop }, this.outputTopic); + + return; + } + + if (item.Message != null) + { + this.messageThread.Add(item.Message); + } + + await this.ValidateGroupState(item.Message); + + if (item.Message == null) + { + await this.ProcessNextSpeakerAsync(); + } + else + { + await this.ProcessNextSpeakerAsync(item.Message); + } + } + + public ValueTask HandleAsync(GroupChatAgentResponse item, CancellationToken cancellationToken) + { + List delta = new List(); + + if (item.AgentResponse.InnerMessages != null) + { + this.messageThread.AddRange(item.AgentResponse.InnerMessages); + delta.AddRange(item.AgentResponse.InnerMessages); + } + + this.messageThread.Add(item.AgentResponse.Message); + delta.Add(item.AgentResponse.Message); + + return this.ProcessNextSpeakerAsync(delta); + } + + private async ValueTask ShouldTerminateAsync(params IList incomingMessages) + { + if (this.terminationCondition == null) + { + return false; + } + + StopMessage? stopMessage = await this.terminationCondition.UpdateAsync(incomingMessages); + if (stopMessage != null) + { + await this.PublishMessageAsync(new GroupChatTermination { Message = stopMessage }, this.outputTopic); + return true; + } + + return false; + } + + private async ValueTask ProcessNextSpeakerAsync(params IList incomingMessages) + { + if (!await this.ShouldTerminateAsync(incomingMessages)) + { + string nextSpeakerTopic = await this.SelectSpeakerAsync(this.messageThread); + await this.PublishMessageAsync(new GroupChatRequestPublish { }, nextSpeakerTopic); + } + } + + public ValueTask HandleAsync(object item, CancellationToken cancellationToken) + { + throw new InvalidOperationException($"Unhandled message in group chat manager: {item.GetType()}"); + } +} diff --git a/dotnet/src/Microsoft.AutoGen/AgentChat/GroupChat/RoundRobinGroupChat.cs b/dotnet/src/Microsoft.AutoGen/AgentChat/GroupChat/RoundRobinGroupChat.cs new file mode 100644 index 000000000000..bd4ca3eb0172 --- /dev/null +++ b/dotnet/src/Microsoft.AutoGen/AgentChat/GroupChat/RoundRobinGroupChat.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// RoundRobinGroupChat.cs + +using Microsoft.AutoGen.Abstractions; + +// Copyright (c) Microsoft Corporation. All rights reserved. +// RoundRobinGroupChat.cs + +using Microsoft.AutoGen.AgentChat.Abstractions; +using Microsoft.AutoGen.Agents; + +namespace Microsoft.AutoGen.AgentChat.GroupChat; + +public class RoundRobinGroupChatManager : GroupChatManagerBase +{ + private readonly List participantNames; + //private int currentIndex; + + public RoundRobinGroupChatManager(IAgentRuntime context, EventTypes eventTypes, AgentChatBinder configurator) : base(context, eventTypes, configurator) + { + this.participantNames = configurator.ParticipantTopics.ToList(); + } + + public override ValueTask SelectSpeakerAsync(List thread) + { + throw new NotImplementedException(); + } + + //override Reset() +} + +public class RoundRobinGroupChat : GroupChatBase +{ + public RoundRobinGroupChat(List participants, ITerminationCondition? terminationCondition = null) : base(participants, terminationCondition) + { + } +} diff --git a/dotnet/src/Microsoft.AutoGen/AgentChat/GroupChat/SequentialRoutedAgent.cs b/dotnet/src/Microsoft.AutoGen/AgentChat/GroupChat/SequentialRoutedAgent.cs new file mode 100644 index 000000000000..66268527185f --- /dev/null +++ b/dotnet/src/Microsoft.AutoGen/AgentChat/GroupChat/SequentialRoutedAgent.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// SequentialRoutedAgent.cs + +// TODO: Inconsistency viz Python +using Microsoft.AutoGen.Abstractions; +using Microsoft.AutoGen.Agents; + +namespace Microsoft.AutoGen.AgentChat.GroupChat; + +public interface ITodoMakeProto +{ + public Google.Protobuf.IMessage ToProtobufMessage() + { + throw new NotImplementedException(); + } +} + +public class TeamChatAgentScaffolding : AgentBase +{ + public TeamChatAgentScaffolding(IAgentRuntime context, EventTypes eventTypes) : base(context, eventTypes) + { + } + + public new ValueTask PublishMessageAsync(T message, string? source = null, CancellationToken token = default) where T : ITodoMakeProto + { + return base.PublishMessageAsync(message.ToProtobufMessage(), source, token); + } +} + +// This scaffolding is probably unneeded? +public class SequentialRoutedAgent : TeamChatAgentScaffolding +{ + public SequentialRoutedAgent(IAgentRuntime context, EventTypes eventTypes) : base(context, eventTypes) + { + } +} diff --git a/dotnet/src/Microsoft.AutoGen/AgentChat/Microsoft.AutoGen.AgentChat.csproj b/dotnet/src/Microsoft.AutoGen/AgentChat/Microsoft.AutoGen.AgentChat.csproj new file mode 100644 index 000000000000..dc894d25ad2c --- /dev/null +++ b/dotnet/src/Microsoft.AutoGen/AgentChat/Microsoft.AutoGen.AgentChat.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + enable + enable + AutoGen.AgentChat + https://github.com/microsoft/autogen + Microsoft + AutoGen agents and teams library + ai-agents;event-driven-agents;agent-team + + + + + + + + + + + + + + + diff --git a/dotnet/src/Microsoft.AutoGen/Agents/AgentBase.cs b/dotnet/src/Microsoft.AutoGen/Agents/AgentBase.cs index da3337ab0145..1b59ba070051 100644 --- a/dotnet/src/Microsoft.AutoGen/Agents/AgentBase.cs +++ b/dotnet/src/Microsoft.AutoGen/Agents/AgentBase.cs @@ -171,6 +171,8 @@ public List Subscribe(string topic) } } }; + + // TODO: Should this be async? _runtime.SendMessageAsync(message).AsTask().Wait(); return new List { topic }; diff --git a/dotnet/src/Microsoft.AutoGen/Extensions/DotNetInteractive/Microsoft.AutoGen.Extensions.DotNetInteractive.csproj b/dotnet/src/Microsoft.AutoGen/Extensions/DotNetInteractive/Microsoft.AutoGen.Extensions.DotNetInteractive.csproj new file mode 100644 index 000000000000..45186a6709b6 --- /dev/null +++ b/dotnet/src/Microsoft.AutoGen/Extensions/DotNetInteractive/Microsoft.AutoGen.Extensions.DotNetInteractive.csproj @@ -0,0 +1,17 @@ + + + + + + + + net8.0 + enable + enable + + + + + + + diff --git a/protos/agentchat_events.proto b/protos/agentchat_events.proto new file mode 100644 index 000000000000..91f18f3f6fdb --- /dev/null +++ b/protos/agentchat_events.proto @@ -0,0 +1,10 @@ +syntax = "proto3"; + +package agents; + +option csharp_namespace = "Microsoft.AutoGen.AgentChat.Abstractions"; + +// This should really, properly, live in AgentChat +message TodoMessages { + string TODO = 1; +} \ No newline at end of file