diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 3e2529a0b7..9ba4eb3ae2 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -83,10 +83,10 @@ - + - + diff --git a/dotnet/samples/A2AClientServer/A2AServer/Models/InvoiceQuery.cs b/dotnet/samples/A2AClientServer/A2AServer/Models/InvoiceQuery.cs index 56ba14b49f..2b2d142c46 100644 --- a/dotnet/samples/A2AClientServer/A2AServer/Models/InvoiceQuery.cs +++ b/dotnet/samples/A2AClientServer/A2AServer/Models/InvoiceQuery.cs @@ -118,7 +118,7 @@ public class InvoiceQuery public static DateTime GetRandomDateWithinLastTwoMonths() { // Get the current date and time - DateTime endDate = DateTime.Now; + DateTime endDate = DateTime.UtcNow; // Calculate the start date, which is two months before the current date DateTime startDate = endDate.AddMonths(-2); diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Home.razor b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Home.razor index 9db3d9efb6..cc3a2576b3 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Home.razor +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Home.razor @@ -909,7 +909,7 @@ private sealed class Conversation { - public string SessionId { get; set; } = Guid.NewGuid().ToString(); + public string SessionId { get; set; } = Guid.NewGuid().ToString("N"); public string AgentName { get; set; } = ""; public List Messages { get; set; } = new(); } diff --git a/dotnet/samples/GettingStarted/AgentOpenTelemetry/Program.cs b/dotnet/samples/GettingStarted/AgentOpenTelemetry/Program.cs index 1ee48514f8..10c6a058ff 100644 --- a/dotnet/samples/GettingStarted/AgentOpenTelemetry/Program.cs +++ b/dotnet/samples/GettingStarted/AgentOpenTelemetry/Program.cs @@ -123,7 +123,7 @@ appLogger.LogInformation("Agent created successfully with ID: {AgentId}", agent. // Create a parent span for the entire agent session using var sessionActivity = activitySource.StartActivity("Agent Session"); -var sessionId = Guid.NewGuid().ToString(); +var sessionId = Guid.NewGuid().ToString("N"); sessionActivity? .SetTag("agent.name", "OpenTelemetryDemoAgent") .SetTag("session.id", sessionId) diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Program.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Program.cs index fe3e3ba0b2..ed5dfda403 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Program.cs +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Program.cs @@ -31,6 +31,8 @@ namespace SampleApp // Custom agent that parrot's the user input back in upper case. internal sealed class UpperCaseParrotAgent : AIAgent { + public override string? Name => "UpperCaseParrotAgent"; + public override AgentThread GetNewThread() => new CustomAgentThread(); @@ -51,7 +53,7 @@ namespace SampleApp return new AgentRunResponse { AgentId = this.Id, - ResponseId = Guid.NewGuid().ToString(), + ResponseId = Guid.NewGuid().ToString("N"), Messages = responseMessages }; } @@ -75,8 +77,8 @@ namespace SampleApp AuthorName = this.DisplayName, Role = ChatRole.Assistant, Contents = message.Contents, - ResponseId = Guid.NewGuid().ToString(), - MessageId = Guid.NewGuid().ToString() + ResponseId = Guid.NewGuid().ToString("N"), + MessageId = Guid.NewGuid().ToString("N") }; } } @@ -86,7 +88,7 @@ namespace SampleApp // Clone the message and update its author to be the agent. var messageClone = x.Clone(); messageClone.Role = ChatRole.Assistant; - messageClone.MessageId = Guid.NewGuid().ToString(); + messageClone.MessageId = Guid.NewGuid().ToString("N"); messageClone.AuthorName = agentName; // Clone and convert any text content to upper case. @@ -109,8 +111,7 @@ namespace SampleApp /// internal sealed class CustomAgentThread : InMemoryAgentThread { - internal CustomAgentThread() - : base() { } + internal CustomAgentThread() { } internal CustomAgentThread(JsonElement serializedThreadState, JsonSerializerOptions? jsonSerializerOptions = null) : base(serializedThreadState, jsonSerializerOptions) { } diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs index fca3fb99b3..6a57e8e7e7 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs @@ -98,7 +98,7 @@ namespace SampleApp public override async Task AddMessagesAsync(IEnumerable messages, CancellationToken cancellationToken) { - this.ThreadDbKey ??= Guid.NewGuid().ToString(); + this.ThreadDbKey ??= Guid.NewGuid().ToString("N"); var collection = this._vectorStore.GetCollection("ChatHistory"); await collection.EnsureCollectionExistsAsync(cancellationToken); diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step08_Observability/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step08_Observability/Program.cs index e0a32594ef..a5a29c6039 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step08_Observability/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step08_Observability/Program.cs @@ -21,7 +21,7 @@ AppContext.SetSwitch("Microsoft.Extensions.AI.Agents.EnableTelemetry", true); // Create TracerProvider with console exporter // This will output the telemetry data to the console. -string sourceName = Guid.NewGuid().ToString(); +string sourceName = Guid.NewGuid().ToString("N"); using var tracerProvider = Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) .AddConsoleExporter() diff --git a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/01_EdgeCondition/Program.cs b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/01_EdgeCondition/Program.cs index 7b09b9d4d9..1488e93d60 100644 --- a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/01_EdgeCondition/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/01_EdgeCondition/Program.cs @@ -166,7 +166,7 @@ internal sealed class SpamDetectionExecutor : ReflectingExecutor( // Read file content from embedded resource string fileContent = Resources.Read(message); // Store file content in a shared state for access by other executors - string fileID = Guid.NewGuid().ToString(); + string fileID = Guid.NewGuid().ToString("N"); await context.QueueStateUpdateAsync(fileID, fileContent, scopeName: FileContentStateConstants.FileContentStateScope); return fileID; diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/Program.cs index c8604244b5..f936a8f495 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/Program.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json; using System.Threading.Tasks; using Azure.AI.OpenAI; using Azure.Identity; @@ -30,7 +31,7 @@ public static class Program var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; var client = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); - Console.Write("Choose workflow type ('sequential', 'concurrent', 'handoffs'): "); + Console.Write("Choose workflow type ('sequential', 'concurrent', 'handoffs', 'groupchat'): "); switch (Console.ReadLine()) { case "sequential": @@ -58,10 +59,9 @@ public static class Program "You determine which agent to use based on the user's homework question. ALWAYS handoff to another agent.", "triage_agent", "Routes messages to the appropriate specialist agent"); - var workflow = AgentWorkflowBuilder.StartHandoffWith(triageAgent) - .WithHandoff(triageAgent, [mathTutor, historyTutor]) - .WithHandoff(mathTutor, triageAgent) - .WithHandoff(historyTutor, triageAgent) + var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(triageAgent) + .WithHandoffs(triageAgent, [mathTutor, historyTutor]) + .WithHandoffs([mathTutor, historyTutor], triageAgent) .Build(); List messages = []; @@ -72,22 +72,45 @@ public static class Program messages.AddRange(await RunWorkflowAsync(workflow, messages)); } + case "groupchat": + await RunWorkflowAsync( + AgentWorkflowBuilder.CreateGroupChatBuilderWith(agents => new AgentWorkflowBuilder.RoundRobinGroupChatManager(agents) { MaximumIterationCount = 5 }) + .AddParticipants(from lang in (string[])["French", "Spanish", "English"] select GetTranslationAgent(lang, client)) + .Build(), + [new(ChatRole.User, "Hello, world!")]); + break; + default: throw new InvalidOperationException("Invalid workflow type."); } static async Task> RunWorkflowAsync(Workflow> workflow, List messages) { + string? lastExecutorId = null; + StreamingRun run = await InProcessExecution.StreamAsync(workflow, messages); await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { if (evt is AgentRunUpdateEvent e) { - Console.WriteLine($"{e.ExecutorId}: {e.Data}"); + if (e.ExecutorId != lastExecutorId) + { + lastExecutorId = e.ExecutorId; + Console.WriteLine(); + Console.WriteLine(e.ExecutorId); + } + + Console.Write(e.Update.Text); + if (e.Update.Contents.OfType().FirstOrDefault() is FunctionCallContent call) + { + Console.WriteLine(); + Console.WriteLine($" [Calling function '{call.Name}' with arguments: {JsonSerializer.Serialize(call.Arguments)}]"); + } } else if (evt is WorkflowCompletedEvent completed) { + Console.WriteLine(); return (List)completed.Data!; } } diff --git a/dotnet/src/Microsoft.Agents.Orchestration/OrchestratingAgentThread.cs b/dotnet/src/Microsoft.Agents.Orchestration/OrchestratingAgentThread.cs index 05db5d566d..08c33e4fea 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/OrchestratingAgentThread.cs +++ b/dotnet/src/Microsoft.Agents.Orchestration/OrchestratingAgentThread.cs @@ -10,8 +10,7 @@ namespace Microsoft.Agents.Orchestration; /// internal sealed class OrchestratingAgentThread : InMemoryAgentThread { - internal OrchestratingAgentThread() - : base() { } + internal OrchestratingAgentThread() { } internal OrchestratingAgentThread(JsonElement serializedThreadState, JsonSerializerOptions? jsonSerializerOptions = null) : base(serializedThreadState, jsonSerializerOptions) { } diff --git a/dotnet/src/Microsoft.Agents.Workflows/AIAgentsAbstractionsExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows/AIAgentsAbstractionsExtensions.cs index cab241d4a4..4152a3c75b 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/AIAgentsAbstractionsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/AIAgentsAbstractionsExtensions.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Diagnostics; using Microsoft.Extensions.AI; using Microsoft.Extensions.AI.Agents; @@ -18,19 +17,4 @@ internal static class AIAgentsAbstractionsExtensions MessageId = update.MessageId, RawRepresentation = update.RawRepresentation ?? update, }; - - public static ChatMessage UpdateWith(this ChatMessage baseMessage, AgentRunResponseUpdate update) - { - Debug.Assert(update.MessageId is null || baseMessage.MessageId == update.MessageId); - - return new() - { - AuthorName = update.AuthorName ?? baseMessage.AuthorName, - Contents = [.. baseMessage.Contents, .. update.Contents], - Role = update.Role ?? baseMessage.Role, - CreatedAt = update.CreatedAt ?? baseMessage.CreatedAt, - MessageId = baseMessage.MessageId, - RawRepresentation = update.RawRepresentation ?? baseMessage.RawRepresentation ?? update, - }; - } } diff --git a/dotnet/src/Microsoft.Agents.Workflows/AgentWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows/AgentWorkflowBuilder.cs index 3e49919ced..b4ee59244f 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/AgentWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/AgentWorkflowBuilder.cs @@ -2,25 +2,24 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Specialized; using Microsoft.Extensions.AI; using Microsoft.Extensions.AI.Agents; using Microsoft.Shared.Diagnostics; -#if NET -using System.Security.Cryptography; -#endif namespace Microsoft.Agents.Workflows; /// -/// Provides utility methods for constructing common patterns of agent workflows. +/// Provides utility methods for constructing common patterns of workflows composed of agents. /// -public static class AgentWorkflowBuilder +public static partial class AgentWorkflowBuilder { /// /// Builds a composed of a pipeline of agents where the output of one agent is the input to the next. @@ -37,7 +36,7 @@ public static class AgentWorkflowBuilder ExecutorIsh? previous = null; foreach (var agent in agents) { - AIAgentHostExecutor agentExecutor = new(agent); + AgentRunStreamingExecutor agentExecutor = new(agent, includeInputInOutput: true); if (builder is null) { @@ -60,31 +59,11 @@ public static class AgentWorkflowBuilder // Add an ending executor that batches up all messages from the last agent // so that it's published as a single list result. Debug.Assert(builder is not null); - builder.AddEdge(previous, new SequentialEndExecutor()); + builder.AddEdge(previous, new ConvertMessageListToCompletedEventExecutor()); return builder.Build>(); } - /// - /// Provides an executor that batches received chat messages that it then publishes as the final result - /// when receiving a . - /// - private sealed class SequentialEndExecutor : Executor - { - private readonly List _pendingMessages = []; - - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => - routeBuilder - .AddHandler((message, context) => this._pendingMessages.Add(message)) - .AddHandler>((messages, _) => this._pendingMessages.AddRange(messages)) - .AddHandler(async (token, context) => - { - var messages = new List(this._pendingMessages); - this._pendingMessages.Clear(); - await context.AddEventAsync(new WorkflowCompletedEvent(messages)).ConfigureAwait(false); - }); - } - /// /// Builds a composed of agents that operate concurrently on the same input, /// aggregating their outputs into a single collection. @@ -110,8 +89,8 @@ public static class AgentWorkflowBuilder // so that the final accumulator receives a single list of messages from each agent. Otherwise, the // accumulator would not be able to determine what came from what agent, as there's currently no // provenance tracking exposed in the workflow context passed to a handler. - ExecutorIsh[] agentExecutors = (from agent in agents select (ExecutorIsh)agent).ToArray(); - ExecutorIsh[] accumulators = [.. from agent in agentExecutors select (ExecutorIsh)new ChatMessageBatchingExecutor()]; + ExecutorIsh[] agentExecutors = (from agent in agents select (ExecutorIsh)new AgentRunStreamingExecutor(agent, includeInputInOutput: false)).ToArray(); + ExecutorIsh[] accumulators = [.. from agent in agentExecutors select (ExecutorIsh)new BatchChatMessagesToListExecutor()]; builder.AddFanOutEdge(start, targets: agentExecutors); for (int i = 0; i < agentExecutors.Length; i++) { @@ -137,13 +116,96 @@ public static class AgentWorkflowBuilder /// The must be capable of understanding those provided. If the agent /// ignores the tools or is otherwise unable to advertize them to the underlying provider, handoffs will not occur. /// - public static HandoffsWorkflowBuilder StartHandoffWith(AIAgent initialAgent) + public static HandoffsWorkflowBuilder CreateHandoffBuilderWith(AIAgent initialAgent) { Throw.IfNull(initialAgent); return new(initialAgent); } - /// Executor that forwards all relevant messages. + /// Creates a new with . + /// + /// Function that will create the for the workflow instance. The manager will be + /// provided with the set of agents that will participate in the group chat. + /// + /// The builder for creating a workflow based on handoffs. + /// + /// Handoffs between agents are achieved by the current agent invoking an provided to an agent + /// via 's .. + /// The must be capable of understanding those provided. If the agent + /// ignores the tools or is otherwise unable to advertize them to the underlying provider, handoffs will not occur. + /// + public static GroupChatWorkflowBuilder CreateGroupChatBuilderWith(Func, GroupChatManager> managerFactory) + { + Throw.IfNull(managerFactory); + return new GroupChatWorkflowBuilder(managerFactory); + } + + /// + /// Executor that runs the agent and forwards all messages, input and output, to the next executor. + /// + private sealed class AgentRunStreamingExecutor(AIAgent agent, bool includeInputInOutput) : Executor(GetDescriptiveIdFromAgent(agent)) + { + private readonly List _pendingMessages = []; + + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => + routeBuilder + .AddHandler((message, context) => this._pendingMessages.Add(new(ChatRole.User, message))) + .AddHandler((message, context) => this._pendingMessages.Add(message)) + .AddHandler>((messages, _) => this._pendingMessages.AddRange(messages)) + .AddHandler((messages, _) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed + .AddHandler>((messages, _) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed + .AddHandler(async (token, context) => + { + List messages = [.. this._pendingMessages]; + this._pendingMessages.Clear(); + + List? roleChanged = ChangeAssistantToUserForOtherParticipants(agent.DisplayName, messages); + + List updates = []; + await foreach (var update in agent.RunStreamingAsync(messages).ConfigureAwait(false)) + { + updates.Add(update); + if (token.EmitEvents is true) + { + await context.AddEventAsync(new AgentRunUpdateEvent(this.Id, update)).ConfigureAwait(false); + } + } + + ResetUserToAssistantForChangedRoles(roleChanged); + + if (!includeInputInOutput) + { + messages.Clear(); + } + + messages.AddRange(updates.ToAgentRunResponse().Messages); + + await context.SendMessageAsync(messages).ConfigureAwait(false); + await context.SendMessageAsync(token).ConfigureAwait(false); + }); + } + + /// + /// Provides an executor that batches received chat messages that it then publishes as the final result + /// when receiving a . + /// + private sealed class ConvertMessageListToCompletedEventExecutor : Executor + { + private readonly List _pendingMessages = []; + + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => + routeBuilder + .AddHandler((message, context) => this._pendingMessages.Add(message)) + .AddHandler>((messages, _) => this._pendingMessages.AddRange(messages)) + .AddHandler(async (token, context) => + { + var messages = new List(this._pendingMessages); + this._pendingMessages.Clear(); + await context.AddEventAsync(new WorkflowCompletedEvent(messages)).ConfigureAwait(false); + }); + } + + /// Executor that forwards all messages. private sealed class ForwardingExecutor : Executor { protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => @@ -154,7 +216,7 @@ public static class AgentWorkflowBuilder /// Provides an executor that batches received chat messages that it then releases when /// receiving a . /// - private sealed class ChatMessageBatchingExecutor : Executor + private sealed class BatchChatMessagesToListExecutor : Executor { private readonly List _pendingMessages = []; @@ -195,26 +257,36 @@ public static class AgentWorkflowBuilder protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => routeBuilder.AddHandler>(async (messages, context) => { - this._allResults.Add(messages); - if (--this._remaining == 0) + // TODO: https://github.com/microsoft/agent-framework/issues/784 + // This locking should not be necessary. + bool done; + lock (this._allResults) + { + this._allResults.Add(messages); + done = --this._remaining == 0; + } + + if (done) { this._remaining = this._expectedInputs; + var results = this._allResults; this._allResults = new List>(this._expectedInputs); + await context.AddEventAsync(new WorkflowCompletedEvent(this._aggregator(results))).ConfigureAwait(false); } }); } /// - /// Defines the orchestration handoff relationships for all agents in the system. + /// Provides a builder for specifying the handoff relationships between agents and building the resulting workflow. /// public sealed class HandoffsWorkflowBuilder { private const string FunctionPrefix = "handoff_to_"; private readonly AIAgent _initialAgent; private readonly Dictionary> _targets = []; - private readonly Dictionary _allAgents = []; + private readonly HashSet _allAgents = new(AIAgentIDEqualityComparer.Instance); /// /// Initializes a new instance of the class with no handoff relationships. @@ -223,11 +295,11 @@ public static class AgentWorkflowBuilder internal HandoffsWorkflowBuilder(AIAgent initialAgent) { this._initialAgent = initialAgent; - this._allAgents.Add(initialAgent.Id, initialAgent); + this._allAgents.Add(initialAgent); } /// - /// Gets or sets additional instructions to provide to an agent about how to perform handoffs. + /// Gets or sets additional instructions to provide to an agent that has handoffs about how and when to perform them. /// /// /// By default, simple instructions are included. This may be set to to avoid including @@ -235,9 +307,10 @@ public static class AgentWorkflowBuilder /// public string? HandoffInstructions { get; set; } = $""" - You are part of a multi-agent system. Each agent encompasses instructions and tools and can hand off a conversation to another agent - when appropriate. Handoffs are achieved by calling a handoff function, generally named `{FunctionPrefix}`. Handoffs - between agents are handled seamlessly in the background; do not mention or draw attention to these handoffs in your conversation with the user. + You are one agent in a multi-agent system. You can hand off the conversation to another agent if appropriate. Handoffs are achieved + by calling a handoff function, named in the form `{FunctionPrefix}`; the description of the function provides details on the + target agent of that handoff. Handoffs between agents are handled seamlessly in the background; never mention or narrate these handoffs + in your conversation with the user. """; /// @@ -246,8 +319,8 @@ public static class AgentWorkflowBuilder /// The source agent. /// The target agents to add as handoff targets for the source agent. /// The updated instance. - /// The handoff reason for each target is derived from its description or name. - public HandoffsWorkflowBuilder WithHandoff(AIAgent from, IEnumerable to) + /// The handoff reason for each target in is derived from that agent's description or name. + public HandoffsWorkflowBuilder WithHandoffs(AIAgent from, IEnumerable to) { Throw.IfNull(from); Throw.IfNull(to); @@ -265,32 +338,51 @@ public static class AgentWorkflowBuilder return this; } + /// + /// Adds handoff relationships from one or more sources agent to a target agent. + /// + /// The source agents. + /// The target agent to add as a handoff target for each source agent. + /// + /// The reason the should hand off to the . + /// If , the reason is derived from 's description or name. + /// + /// The updated instance. + public HandoffsWorkflowBuilder WithHandoffs(IEnumerable from, AIAgent to, string? handoffReason = null) + { + Throw.IfNull(from); + Throw.IfNull(to); + + foreach (var source in from) + { + if (source is null) + { + Throw.ArgumentNullException(nameof(from), "One or more source agents are null."); + } + + this.WithHandoff(source, to, handoffReason); + } + + return this; + } + /// /// Adds a handoff relationship from a source agent to a target agent with a custom handoff reason. /// /// The source agent. /// The target agent. - /// The reason the should hand off to the . + /// + /// The reason the should hand off to the . + /// If , the reason is derived from 's description or name. + /// /// The updated instance. public HandoffsWorkflowBuilder WithHandoff(AIAgent from, AIAgent to, string? handoffReason = null) { Throw.IfNull(from); Throw.IfNull(to); -#if NET - this._allAgents.TryAdd(from.Id, from); - this._allAgents.TryAdd(to.Id, to); -#else - if (!this._allAgents.ContainsKey(from.Id)) - { - this._allAgents.Add(from.Id, from); - } - - if (!this._allAgents.ContainsKey(to.Id)) - { - this._allAgents.Add(to.Id, to); - } -#endif + this._allAgents.Add(from); + this._allAgents.Add(to); if (!this._targets.TryGetValue(from, out var handoffs)) { @@ -324,12 +416,12 @@ public static class AgentWorkflowBuilder /// The workflow built based on the handoffs in the builder. public Workflow> Build() { - StartHandoffs start = new(); - EndExecutor end = new(); + StartHandoffsExecutor start = new(); + EndHandoffsExecutor end = new(); WorkflowBuilder builder = new(start); // Create an AgentExecutor for each again. - Dictionary executors = this._allAgents.ToDictionary(a => a.Key, a => new AgentExecutor(a.Value, this.HandoffInstructions)); + Dictionary executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, this.HandoffInstructions)); // Connect the start executor to the initial agent. builder.AddEdge(start, executors[this._initialAgent.Id]); @@ -337,8 +429,8 @@ public static class AgentWorkflowBuilder // Initialize each executor with its handoff targets to the other executors. foreach (var agent in this._allAgents) { - executors[agent.Key].Initialize(builder, end, executors, - this._targets.TryGetValue(agent.Value, out HashSet? targets) ? targets : []); + executors[agent.Id].Initialize(builder, end, executors, + this._targets.TryGetValue(agent, out HashSet? targets) ? targets : []); } // Build the workflow. @@ -353,7 +445,7 @@ public static class AgentWorkflowBuilder } /// Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token. - private sealed class StartHandoffs : Executor + private sealed class StartHandoffsExecutor : Executor { private readonly List _pendingMessages = []; @@ -373,7 +465,7 @@ public static class AgentWorkflowBuilder } /// Executor used at the end of a handoff workflow to raise a final completed event. - private sealed class EndExecutor : Executor + private sealed class EndHandoffsExecutor : Executor { protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => routeBuilder.AddHandler((handoff, context) => @@ -381,45 +473,47 @@ public static class AgentWorkflowBuilder } /// Executor used to represent an agent in a handoffs workflow, responding to events. - private sealed class AgentExecutor( + private sealed class HandoffAgentExecutor( AIAgent agent, - string? instructions) : Executor($"{agent.DisplayName}/{CreateId()}") + string? handoffInstructions) : Executor(GetDescriptiveIdFromAgent(agent)) { private static readonly JsonElement s_handoffSchema = AIFunctionFactory.Create( ([Description("The reason for the handoff")] string? reasonForHandoff) => { }).JsonSchema; - private static readonly AIFunctionDeclaration s_endFunction = AIFunctionFactory.CreateDeclaration( - name: $"end_{CreateId()}", - description: "Invoke this function when all work is completed and no further interactions are required.", - jsonSchema: AIFunctionFactory.Create(() => { }).JsonSchema); private readonly AIAgent _agent = agent; private readonly HashSet _handoffFunctionNames = []; - private readonly ChatClientAgentRunOptions _agentOptions = new() - { - ChatOptions = new() - { - Instructions = instructions, - Tools = [s_endFunction], - } - }; + private ChatClientAgentRunOptions? _agentOptions; public void Initialize( WorkflowBuilder builder, Executor end, - Dictionary executors, - IEnumerable handoffs) => + Dictionary executors, + HashSet handoffs) => builder.AddSwitch(this, sb => { - foreach (HandoffTarget handoff in handoffs) + if (handoffs.Count != 0) { - var handoffFunc = AIFunctionFactory.CreateDeclaration($"{FunctionPrefix}{CreateId()}", handoff.Reason, s_handoffSchema); + Debug.Assert(this._agentOptions is null); + this._agentOptions = new() + { + ChatOptions = new() + { + AllowMultipleToolCalls = false, + Instructions = handoffInstructions, + Tools = [], + }, + }; - this._handoffFunctionNames.Add(handoffFunc.Name); + foreach (HandoffTarget handoff in handoffs) + { + var handoffFunc = AIFunctionFactory.CreateDeclaration($"{FunctionPrefix}{GetDescriptiveIdFromAgent(handoff.Target)}", handoff.Reason, s_handoffSchema); - this._agentOptions.ChatOptions!.Tools!.Add(handoffFunc); - this._agentOptions.ChatOptions.AllowMultipleToolCalls = false; + this._handoffFunctionNames.Add(handoffFunc.Name); - sb.AddCase(state => state?.InvokedHandoff == handoffFunc.Name, executors[handoff.Target.Id]); + this._agentOptions.ChatOptions.Tools.Add(handoffFunc); + + sb.AddCase(state => state?.InvokedHandoff == handoffFunc.Name, executors[handoff.Target.Id]); + } } sb.WithDefault(end); @@ -432,43 +526,34 @@ public static class AgentWorkflowBuilder List updates = []; List allMessages = handoffState.Messages; - while (requestedHandoff is null) + List? roleChanges = ChangeAssistantToUserForOtherParticipants(this._agent.DisplayName, allMessages); + + await foreach (var update in this._agent.RunStreamingAsync(allMessages, options: this._agentOptions).ConfigureAwait(false)) { - updates.Clear(); - await foreach (var update in this._agent.RunStreamingAsync(allMessages, options: this._agentOptions).ConfigureAwait(false)) + await AddUpdateAsync(update).ConfigureAwait(false); + + foreach (var c in update.Contents) { - await AddUpdateAsync(update).ConfigureAwait(false); - for (int i = 0; i < update.Contents.Count; i++) + if (c is FunctionCallContent fcc && this._handoffFunctionNames.Contains(fcc.Name)) { - var c = update.Contents[i]; - if (c is FunctionCallContent fcc) + requestedHandoff = fcc.Name; + await AddUpdateAsync(new AgentRunResponseUpdate { - if (this._handoffFunctionNames.Contains(fcc.Name)) - { - requestedHandoff = fcc.Name; - await AddUpdateAsync(new AgentRunResponseUpdate - { - AgentId = this._agent.Id, - AuthorName = this._agent.DisplayName, - Contents = [new FunctionResultContent(fcc.CallId, "Transferred.")], - CreatedAt = DateTimeOffset.UtcNow, - MessageId = Guid.NewGuid().ToString("N"), - Role = ChatRole.Tool, - }).ConfigureAwait(false); - } - else if (fcc.Name == s_endFunction.Name) - { - requestedHandoff = s_endFunction.Name; - update.Contents.RemoveAt(i); - i--; - } - } + AgentId = this._agent.Id, + AuthorName = this._agent.DisplayName, + Contents = [new FunctionResultContent(fcc.CallId, "Transferred.")], + CreatedAt = DateTimeOffset.UtcNow, + MessageId = Guid.NewGuid().ToString("N"), + Role = ChatRole.Tool, + }).ConfigureAwait(false); } } - - allMessages.AddRange(updates.ToAgentRunResponse().Messages); } + allMessages.AddRange(updates.ToAgentRunResponse().Messages); + + ResetUserToAssistantForChangedRoles(roleChanges); + await context.SendMessageAsync(new HandoffState(handoffState.TurnToken, requestedHandoff, allMessages)).ConfigureAwait(false); async Task AddUpdateAsync(AgentRunResponseUpdate update) @@ -486,12 +571,297 @@ public static class AgentWorkflowBuilder TurnToken TurnToken, string? InvokedHandoff, List Messages); + } - private static string CreateId() => + /// + /// A manager that manages the flow of a group chat. + /// + public abstract class GroupChatManager + { + private int _maximumIterationCount = 40; + + /// + /// Initializes a new instance of the class. + /// + protected GroupChatManager() { } + + /// + /// Gets the number of iterations in the group chat so far. + /// + public int IterationCount { get; internal set; } + + /// + /// Gets or sets the maximum number of iterations allowed. + /// + /// + /// Each iteration involves a single interaction with a participating agent. + /// The default is 40. + /// + public int MaximumIterationCount + { + get => this._maximumIterationCount; + set => this._maximumIterationCount = Throw.IfLessThan(value, 1); + } + + /// + /// Selects the next agent to participate in the group chat based on the provided chat history and team. + /// + /// The chat history to consider. + /// A cancellation token that can be used to cancel the operation. + /// The next to speak. This agent must be part of the chat. + protected internal abstract ValueTask SelectNextAgentAsync( + IReadOnlyList history, + CancellationToken cancellationToken = default); + + /// + /// Filters the chat history before it's passed to the next agent. + /// + /// The chat history to filter. + /// A cancellation token that can be used to cancel the operation. + /// The filtered chat history. + protected internal virtual ValueTask> UpdateHistoryAsync( + IReadOnlyList history, + CancellationToken cancellationToken = default) => + new(history); + + /// + /// Determines whether the group chat should be terminated based on the provided chat history and iteration 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. + protected internal virtual ValueTask ShouldTerminateAsync( + IReadOnlyList history, + CancellationToken cancellationToken = default) => + new(this.MaximumIterationCount is int max && this.IterationCount >= max); + + /// + /// Resets the state of the manager for a new group chat session. + /// + protected internal virtual void Reset() + { + this.IterationCount = 0; + } + } + + /// + /// Provides a that selects agents in a round-robin fashion. + /// + public class RoundRobinGroupChatManager : GroupChatManager + { + private readonly IReadOnlyList _agents; + private readonly Func, CancellationToken, ValueTask>? _shouldTerminateFunc; + private int _nextIndex; + + /// + /// Initializes a new instance of the class. + /// + /// The agents to be managed as part of this workflow. + /// + /// An optional function that determines whether the group chat should terminate based on the chat history + /// before factoring in the default behavior, which is to terminate based only on the iteration count. + /// + public RoundRobinGroupChatManager( + IReadOnlyList agents, + Func, CancellationToken, ValueTask>? shouldTerminateFunc = null) + { + Throw.IfNullOrEmpty(agents); + foreach (var agent in agents) + { + Throw.IfNull(agent, nameof(agents)); + } + + this._agents = agents; + this._shouldTerminateFunc = shouldTerminateFunc; + } + + /// + protected internal override ValueTask SelectNextAgentAsync( + IReadOnlyList history, CancellationToken cancellationToken = default) + { + AIAgent nextAgent = this._agents[this._nextIndex]; + + this._nextIndex = (this._nextIndex + 1) % this._agents.Count; + + return new ValueTask(nextAgent); + } + + /// + protected internal override async ValueTask ShouldTerminateAsync( + IReadOnlyList history, CancellationToken cancellationToken = default) + { + if (this._shouldTerminateFunc is { } func && await func(this, history, cancellationToken).ConfigureAwait(false)) + { + return true; + } + + return await base.ShouldTerminateAsync(history, cancellationToken).ConfigureAwait(false); + } + + /// + protected internal override void Reset() + { + base.Reset(); + this._nextIndex = 0; + } + } + + /// + /// Provides a builder for specifying group chat relationships between agents and building the resulting workflow. + /// + public sealed class GroupChatWorkflowBuilder + { + private readonly Func, GroupChatManager> _managerFactory; + private readonly HashSet _participants = new(AIAgentIDEqualityComparer.Instance); + + internal GroupChatWorkflowBuilder(Func, GroupChatManager> managerFactory) => + this._managerFactory = managerFactory; + + /// + /// Adds the specified as participants to the group chat workflow. + /// + /// The agents to add as participants. + /// This instance of the . + public GroupChatWorkflowBuilder AddParticipants(params IEnumerable agents) + { + Throw.IfNull(agents); + + foreach (var agent in agents) + { + if (agent is null) + { + Throw.ArgumentNullException(nameof(agents), "One or more target agents are null."); + } + + this._participants.Add(agent); + } + + return this; + } + + /// + /// Builds a composed of agents that operate via group chat, with the next + /// agent to process messages selected by the group chat manager. + /// + /// The workflow built based on the group chat in the builder. + public Workflow> Build() + { + AIAgent[] agents = this._participants.ToArray(); + Dictionary agentMap = agents.ToDictionary(a => a, a => (ExecutorIsh)new AgentRunStreamingExecutor(a, includeInputInOutput: true)); + + GroupChatHost host = new(agents, agentMap, this._managerFactory); + + WorkflowBuilder builder = new(host); + + foreach (var participant in agentMap.Values) + { + builder + .AddEdge(host, participant) + .AddEdge(participant, host); + } + + return builder.Build>(); + } + + private sealed class GroupChatHost(AIAgent[] agents, Dictionary agentMap, Func, GroupChatManager> managerFactory) : Executor + { + private readonly AIAgent[] _agents = agents; + private readonly Dictionary _agentMap = agentMap; + private readonly Func, GroupChatManager> _managerFactory = managerFactory; + private readonly List _pendingMessages = []; + + private GroupChatManager? _manager; + + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => routeBuilder + .AddHandler((message, context) => this._pendingMessages.Add(new(ChatRole.User, message))) + .AddHandler((message, context) => this._pendingMessages.Add(message)) + .AddHandler>((messages, _) => this._pendingMessages.AddRange(messages)) + .AddHandler((messages, _) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed + .AddHandler>((messages, _) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed + .AddHandler(async (token, context) => + { + List messages = [.. this._pendingMessages]; + this._pendingMessages.Clear(); + + this._manager ??= this._managerFactory(this._agents); + + if (!await this._manager.ShouldTerminateAsync(messages).ConfigureAwait(false)) + { + var filtered = await this._manager.UpdateHistoryAsync(messages).ConfigureAwait(false); + messages = filtered is null || ReferenceEquals(filtered, messages) ? messages : [.. filtered]; + + if (await this._manager.SelectNextAgentAsync(messages).ConfigureAwait(false) is AIAgent nextAgent && + this._agentMap.TryGetValue(nextAgent, out var executor)) + { + this._manager.IterationCount++; + await context.SendMessageAsync(messages, executor.Id).ConfigureAwait(false); + await context.SendMessageAsync(token, executor.Id).ConfigureAwait(false); + return; + } + } + + this._manager = null; + await context.AddEventAsync(new WorkflowCompletedEvent(messages)).ConfigureAwait(false); + }); + } + } + + /// + /// Iterates through looking for messages and swapping + /// any that have a different from to . + /// + private static List? ChangeAssistantToUserForOtherParticipants(string targetAgentName, List messages) + { + List? roleChanged = null; + foreach (var m in messages) + { + if (m.Role == ChatRole.Assistant && + m.AuthorName != targetAgentName && + m.Contents.All(c => c is TextContent or DataContent or UriContent or UsageContent)) + { + m.Role = ChatRole.User; + (roleChanged ??= []).Add(m); + } + } + + return roleChanged; + } + + /// + /// Undoes changes made by + /// when passed the list of changes made by that method. + /// + private static void ResetUserToAssistantForChangedRoles(List? roleChanged) + { + if (roleChanged is not null) + { + foreach (var m in roleChanged) + { + m.Role = ChatRole.Assistant; + } + } + } + + /// Derives from an agent a unique but also hopefully descriptive name that can be used as an executor's name or in a function name. + private static string GetDescriptiveIdFromAgent(AIAgent agent) + { + string id = string.IsNullOrEmpty(agent.Name) ? agent.Id : $"{agent.Name}_{agent.Id}"; + return InvalidNameCharsRegex().Replace(id, "_"); + } + + /// Regex that flags any character other than ASCII digits or letters or the underscore. #if NET - RandomNumberGenerator.GetString("abcdefghijklmnopqrstuvwxyz0123456789", 24); + [GeneratedRegex("[^0-9A-Za-z_]+")] + private static partial Regex InvalidNameCharsRegex(); #else - Guid.NewGuid().ToString("N"); + private static Regex InvalidNameCharsRegex() => s_invalidNameCharsRegex; + private static readonly Regex s_invalidNameCharsRegex = new("[^0-9A-Za-z_]+", RegexOptions.Compiled); #endif + + private sealed class AIAgentIDEqualityComparer : IEqualityComparer + { + public static AIAgentIDEqualityComparer Instance { get; } = new(); + public bool Equals(AIAgent? x, AIAgent? y) => x?.Id == y?.Id; + public int GetHashCode([DisallowNull] AIAgent obj) => obj?.GetHashCode() ?? 0; } } diff --git a/dotnet/src/Microsoft.Agents.Workflows/MessageMerger.cs b/dotnet/src/Microsoft.Agents.Workflows/MessageMerger.cs index 1a7e0c7b63..1b9cfefbce 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/MessageMerger.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/MessageMerger.cs @@ -57,9 +57,7 @@ internal sealed class MessageMerger public List ComputeFlattened() { - List result = this.UpdatesByMessageId.Keys.Select(AggregateUpdatesToMessage) - .ToList(); - + List result = this.UpdatesByMessageId.Keys.SelectMany(AggregateUpdatesToMessage).ToList(); if (this.DanglingUpdates.Count > 0) { result.AddRange(this.ComputeDangling().Messages); @@ -67,7 +65,7 @@ internal sealed class MessageMerger return result; - ChatMessage AggregateUpdatesToMessage(string messageId) + IList AggregateUpdatesToMessage(string messageId) { List updates = this.UpdatesByMessageId[messageId]; if (updates.Count == 0) @@ -75,13 +73,19 @@ internal sealed class MessageMerger throw new InvalidOperationException($"No updates found for message ID '{messageId}' in response '{this.ResponseId}'."); } - return updates.Aggregate(null, - (ChatMessage? previous, AgentRunResponseUpdate current) => + return updates.Select(oldUpdate => + oldUpdate.RawRepresentation as ChatResponseUpdate ?? + new() { - return previous is null - ? current.ToChatMessage() - : previous.UpdateWith(current); - })!; + AdditionalProperties = oldUpdate.AdditionalProperties, + AuthorName = oldUpdate.AuthorName, + Contents = oldUpdate.Contents, + CreatedAt = oldUpdate.CreatedAt, + MessageId = oldUpdate.MessageId, + RawRepresentation = oldUpdate.RawRepresentation, + Role = oldUpdate.Role, + ResponseId = oldUpdate.ResponseId, + }).ToChatResponse().Messages; } } } @@ -170,13 +174,28 @@ internal sealed class MessageMerger } messages.AddRange(this._danglingState.ComputeFlattened()); + + // Remove any empty text contents or messages that are now empty. + foreach (var m in messages) + { + for (int i = m.Contents.Count - 1; i >= 0; i--) + { + if (m.Contents[i] is TextContent textContent && + string.IsNullOrWhiteSpace(textContent.Text)) + { + m.Contents.RemoveAt(i); + } + } + } + messages.RemoveAll(m => m.Contents.Count == 0); + return new AgentRunResponse(messages) { ResponseId = primaryResponseId, AgentId = primaryAgentId ?? primaryAgentName ?? (agentIds.Count == 1 ? agentIds.First() : null), - CreatedAt = DateTimeOffset.Now, + CreatedAt = DateTimeOffset.UtcNow, Usage = usage, AdditionalProperties = additionalProperties }; diff --git a/dotnet/src/Microsoft.Agents.Workflows/Run.cs b/dotnet/src/Microsoft.Agents.Workflows/Run.cs index 8435a7658b..a3e8f66666 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Run.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Run.cs @@ -118,7 +118,7 @@ public class Run /// A that can be used to cancel the workflow execution. /// An array of objects to send to the workflow. /// true if the workflow had any output events, false otherwise. - public async ValueTask ResumeAsync(CancellationToken cancellation = default, params ExternalResponse[] responses) + public async ValueTask ResumeAsync(CancellationToken cancellation = default, params IEnumerable responses) { foreach (ExternalResponse response in responses) { @@ -135,9 +135,9 @@ public class Run /// An array of messages to send to the workflow. Messages will only be sent if they are valid /// input types to the starting executor or a . /// true if the workflow had any output events, false otherwise. - public async ValueTask ResumeAsync(CancellationToken cancellation = default, params T[] messages) + public async ValueTask ResumeAsync(CancellationToken cancellation = default, params IEnumerable messages) { - if (messages is ExternalResponse[] responses) + if (messages is IEnumerable responses) { return await this.ResumeAsync(cancellation, responses).ConfigureAwait(false); } diff --git a/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs index cdcb9293b3..6065b3d5d1 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs @@ -115,7 +115,7 @@ internal sealed class AIAgentHostExecutor : Executor async ValueTask PublishCurrentMessageAsync() { - if (currentStreamingMessage is not null) + if (currentStreamingMessage is not null && updates.Count > 0) { currentStreamingMessage.Contents = updates; updates = []; diff --git a/dotnet/src/Microsoft.Agents.Workflows/SwitchBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows/SwitchBuilder.cs index 2411f62294..495ee3474a 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/SwitchBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/SwitchBuilder.cs @@ -30,7 +30,7 @@ public sealed class SwitchBuilder /// One or more executors to associate with the predicate. Each executor will be invoked if the predicate matches. /// Cannot be null. /// The current instance, allowing for method chaining. - public SwitchBuilder AddCase(Func predicate, params ExecutorIsh[] executors) + public SwitchBuilder AddCase(Func predicate, params IEnumerable executors) { Throw.IfNull(predicate); Throw.IfNull(executors); @@ -60,7 +60,7 @@ public sealed class SwitchBuilder /// /// /// - public SwitchBuilder WithDefault(params ExecutorIsh[] executors) + public SwitchBuilder WithDefault(params IEnumerable executors) { Throw.IfNull(executors); diff --git a/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs index 2607898acb..4f53e09af4 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs @@ -211,7 +211,7 @@ public class WorkflowBuilder /// The source executor from which the fan-out edge originates. Cannot be null. /// One or more target executors that will receive the fan-out edge. Cannot be null or empty. /// The current instance of . - public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, params ExecutorIsh[] targets) + public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, params IEnumerable targets) => this.AddFanOutEdge(source, null, targets); internal static Func>? CreateEdgeAssignerFunc(Func>? partitioner) @@ -243,16 +243,24 @@ public class WorkflowBuilder /// If null, messages will route to all targets. /// One or more target executors that will receive the fan-out edge. Cannot be null or empty. /// The current instance of . - public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, Func>? partitioner = null, params ExecutorIsh[] targets) + public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, Func>? partitioner = null, params IEnumerable targets) { Throw.IfNull(source); - Throw.IfNullOrEmpty(targets); + Throw.IfNull(targets); + + List sinkIds = targets.Select(target => + { + Throw.IfNull(target, nameof(targets)); + return this.Track(target).Id; + }).ToList(); + + Throw.IfNullOrEmpty(sinkIds, nameof(targets)); FanOutEdgeData fanOutEdge = new( - this.Track(source).Id, - targets.Select(target => this.Track(target).Id).ToList(), - this.TakeEdgeId(), - CreateEdgeAssignerFunc(partitioner)); + this.Track(source).Id, + sinkIds, + this.TakeEdgeId(), + CreateEdgeAssignerFunc(partitioner)); this.EnsureEdgesFor(source.Id).Add(new(fanOutEdge)); @@ -269,15 +277,23 @@ public class WorkflowBuilder /// The target executor that receives input from the specified source executors. Cannot be null. /// One or more source executors that provide input to the target. Cannot be null or empty. /// The current instance of . - public WorkflowBuilder AddFanInEdge(ExecutorIsh target, params ExecutorIsh[] sources) + public WorkflowBuilder AddFanInEdge(ExecutorIsh target, params IEnumerable sources) { Throw.IfNull(target); - Throw.IfNullOrEmpty(sources); + Throw.IfNull(sources); + + List sourceIds = sources.Select(source => + { + Throw.IfNull(source, nameof(sources)); + return this.Track(source).Id; + }).ToList(); + + Throw.IfNullOrEmpty(sourceIds, nameof(sources)); FanInEdgeData edgeData = new( - sources.Select(source => this.Track(source).Id).ToList(), - this.Track(target).Id, - this.TakeEdgeId()); + sourceIds, + this.Track(target).Id, + this.TakeEdgeId()); foreach (string sourceId in edgeData.SourceIds) { diff --git a/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilderExtensions.cs index d13968fb6c..686d016ee2 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilderExtensions.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Linq; using Microsoft.Agents.Workflows.Specialized; using Microsoft.Shared.Diagnostics; @@ -24,15 +25,19 @@ public static class WorkflowBuilderExtensions /// The source executor from which messages will be forwarded. /// The target executors to which messages will be forwarded. /// The updated instance. - public static WorkflowBuilder ForwardMessage(this WorkflowBuilder builder, ExecutorIsh source, params ExecutorIsh[] executors) + public static WorkflowBuilder ForwardMessage(this WorkflowBuilder builder, ExecutorIsh source, params IEnumerable executors) { - Throw.IfNullOrEmpty(executors); + Throw.IfNull(executors); Func predicate = WorkflowBuilder.CreateConditionFunc((Func)IsAllowedType)!; - if (executors.Length == 1) +#if NET + if (executors.TryGetNonEnumeratedCount(out int count) && count == 1) +#else + if (executors is ICollection { Count: 1 }) +#endif { - return builder.AddEdge(source, executors[0], predicate); + return builder.AddEdge(source, executors.First(), predicate); } return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, executors)); @@ -50,15 +55,19 @@ public static class WorkflowBuilderExtensions /// The source executor from which messages will be forwarded. /// The target executors to which messages, except those of type , will be forwarded. /// The updated instance with the added edges. - public static WorkflowBuilder ForwardExcept(this WorkflowBuilder builder, ExecutorIsh source, params ExecutorIsh[] executors) + public static WorkflowBuilder ForwardExcept(this WorkflowBuilder builder, ExecutorIsh source, params IEnumerable executors) { - Throw.IfNullOrEmpty(executors); + Throw.IfNull(executors); Func predicate = WorkflowBuilder.CreateConditionFunc((Func)IsAllowedType)!; - if (executors.Length == 1) +#if NET + if (executors.TryGetNonEnumeratedCount(out int count) && count == 1) +#else + if (executors is ICollection { Count: 1 }) +#endif { - return builder.AddEdge(source, executors[0], predicate); + return builder.AddEdge(source, executors.First(), predicate); } return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, executors)); @@ -79,25 +88,25 @@ public static class WorkflowBuilderExtensions /// An ordered array of executors to be added to the chain after the source. /// The original workflow builder instance with the specified executor chain added. /// Thrown if there is a cycle in the chain. - public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh source, params ExecutorIsh[] executors) + public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh source, params IEnumerable executors) { Throw.IfNull(builder); Throw.IfNull(source); HashSet seenExecutors = [source.Id]; - for (int i = 0; i < executors.Length; i++) + foreach (var executor in executors) { - Throw.IfNull(executors[i], nameof(executors) + $"[{i}]"); + Throw.IfNull(executor, nameof(executors)); - if (seenExecutors.Contains(executors[i].Id)) + if (seenExecutors.Contains(executor.Id)) { - throw new ArgumentException($"Executor '{executors[i].Id}' is already in the chain.", nameof(executors)); + throw new ArgumentException($"Executor '{executor.Id}' is already in the chain.", nameof(executors)); } - seenExecutors.Add(executors[i].Id); + seenExecutors.Add(executor.Id); - builder.AddEdge(source, executors[i]); - source = executors[i]; + builder.AddEdge(source, executor); + source = executor; } return builder; diff --git a/dotnet/src/Microsoft.Agents.Workflows/WorkflowMessageStore.cs b/dotnet/src/Microsoft.Agents.Workflows/WorkflowMessageStore.cs index fc3e465cbe..debf77c70b 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/WorkflowMessageStore.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/WorkflowMessageStore.cs @@ -44,7 +44,7 @@ internal sealed class WorkflowMessageStore : ChatMessageStore public IList Messages { get; set; } = []; } - internal void AddMessages(params ChatMessage[] messages) => this._chatMessages.AddRange(messages); + internal void AddMessages(params IEnumerable messages) => this._chatMessages.AddRange(messages); public override Task AddMessagesAsync(IEnumerable messages, CancellationToken cancellationToken) { diff --git a/dotnet/src/Microsoft.Agents.Workflows/WorkflowThread.cs b/dotnet/src/Microsoft.Agents.Workflows/WorkflowThread.cs index 90fe97e222..ad1d192c16 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/WorkflowThread.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/WorkflowThread.cs @@ -36,7 +36,7 @@ internal sealed class WorkflowThread : AgentThread AgentRunResponseUpdate update = new(ChatRole.Assistant, parts) { - CreatedAt = DateTimeOffset.Now, + CreatedAt = DateTimeOffset.UtcNow, MessageId = Guid.NewGuid().ToString("N"), }; diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/A2AAgent.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/A2AAgent.cs index 1f2aa615af..ca7b5818d0 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/A2AAgent.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/A2AAgent.cs @@ -53,7 +53,7 @@ internal sealed class A2AAgent : AIAgent } /// - public override sealed AgentThread GetNewThread() + public sealed override AgentThread GetNewThread() => new A2AAgentThread(); /// diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Extensions/ChatMessageExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Extensions/ChatMessageExtensions.cs index f7a2b09013..9199106e65 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Extensions/ChatMessageExtensions.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Extensions/ChatMessageExtensions.cs @@ -25,7 +25,7 @@ internal static class ChatMessageExtensions return new Message { - MessageId = Guid.NewGuid().ToString(), + MessageId = Guid.NewGuid().ToString("N"), Role = MessageRole.User, Parts = allParts, }; diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AIAgent.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AIAgent.cs index b3fffb486e..0dacd4abdf 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AIAgent.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AIAgent.cs @@ -21,7 +21,7 @@ public abstract class AIAgent /// /// The identifier of the agent. The default is a random GUID value, but for service agents, it will match the id of the agent in the service. /// - public virtual string Id { get; } = Guid.NewGuid().ToString(); + public virtual string Id { get; } = Guid.NewGuid().ToString("N"); /// /// Gets the name of the agent (optional). diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/InMemoryAgentThread.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/InMemoryAgentThread.cs index 59c8a30558..48a39839cf 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/InMemoryAgentThread.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/InMemoryAgentThread.cs @@ -19,7 +19,7 @@ public abstract class InMemoryAgentThread : AgentThread /// An optional to use for storing chat messages. If null, a new instance will be created. protected InMemoryAgentThread(InMemoryChatMessageStore? messageStore = null) { - this.MessageStore = messageStore ?? new InMemoryChatMessageStore(); + this.MessageStore = messageStore ?? []; } /// @@ -28,11 +28,7 @@ public abstract class InMemoryAgentThread : AgentThread /// The messages to initialize the thread with. protected InMemoryAgentThread(IEnumerable messages) { - this.MessageStore = new InMemoryChatMessageStore(); - foreach (var message in messages) - { - this.MessageStore.Add(message); - } + this.MessageStore = [.. messages]; } /// @@ -53,8 +49,7 @@ public abstract class InMemoryAgentThread : AgentThread throw new ArgumentException("The serialized thread state must be a JSON object.", nameof(serializedThreadState)); } - var state = JsonSerializer.Deserialize( - serializedThreadState, + var state = serializedThreadState.Deserialize( AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(InMemoryAgentThreadState))) as InMemoryAgentThreadState; this.MessageStore = diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/ServiceIdAgentThread.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/ServiceIdAgentThread.cs index da15502732..52ba25c48b 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/ServiceIdAgentThread.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/ServiceIdAgentThread.cs @@ -45,8 +45,7 @@ public abstract class ServiceIdAgentThread : AgentThread throw new ArgumentException("The serialized thread state must be a JSON object.", nameof(serializedThreadState)); } - var state = JsonSerializer.Deserialize( - serializedThreadState, + var state = serializedThreadState.Deserialize( AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ServiceIdAgentThreadState))) as ServiceIdAgentThreadState; if (state?.ServiceThreadId is string serviceThreadId) diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.CopilotStudio/CopilotStudioAgent.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.CopilotStudio/CopilotStudioAgent.cs index 39152876d3..f9c047d019 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.CopilotStudio/CopilotStudioAgent.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.CopilotStudio/CopilotStudioAgent.cs @@ -41,7 +41,7 @@ public class CopilotStudioAgent : AIAgent } /// - public override sealed AgentThread GetNewThread() + public sealed override AgentThread GetNewThread() => new CopilotStudioAgentThread(); /// diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Converters/ActorEntitiesConverter.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Converters/ActorEntitiesConverter.cs index a865636a8f..efbce3e81b 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Converters/ActorEntitiesConverter.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Converters/ActorEntitiesConverter.cs @@ -20,7 +20,7 @@ internal static class ActorEntitiesConverter return new Message { - MessageId = response.MessageId ?? Guid.NewGuid().ToString(), + MessageId = response.MessageId ?? Guid.NewGuid().ToString("N"), ContextId = contextId, Role = MessageRole.Agent, Parts = parts diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Converters/MessageConverter.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Converters/MessageConverter.cs index 3a498b426b..56af225f19 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Converters/MessageConverter.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Converters/MessageConverter.cs @@ -177,7 +177,7 @@ internal static class MessageConverter var message = new Message { - MessageId = chatMessage.MessageId ?? Guid.NewGuid().ToString(), + MessageId = chatMessage.MessageId ?? Guid.NewGuid().ToString("N"), Role = ConvertChatRoleToMessageRole(chatMessage.Role), Parts = [] }; diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Internal/A2AAgentWrapper.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Internal/A2AAgentWrapper.cs index 0a722ac0c9..9be1cb037a 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Internal/A2AAgentWrapper.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Internal/A2AAgentWrapper.cs @@ -30,7 +30,7 @@ internal sealed class A2AAgentWrapper public async Task ProcessMessageAsync(MessageSendParams messageSendParams, CancellationToken cancellationToken) { - var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString(); + var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N"); var messageId = messageSendParams.Message.MessageId; var actorId = new ActorId(type: this.GetActorType(), key: contextId!); diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxy.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxy.cs index fc4d55c0ce..5763edc2f3 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxy.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxy.cs @@ -135,7 +135,7 @@ public sealed class AgentProxy : AIAgent Messages = newMessages }; - string messageId = newMessages.LastOrDefault()?.MessageId ?? Guid.NewGuid().ToString(); + string messageId = newMessages.LastOrDefault()?.MessageId ?? Guid.NewGuid().ToString("N"); ActorRequest actorRequest = new( actorId: new ActorId(this.Name, threadId), messageId, diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgent.cs b/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgent.cs index f85fa6f10c..44623cb6da 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgent.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgent.cs @@ -137,6 +137,8 @@ public sealed class ChatClientAgent : AIAgent foreach (ChatMessage chatResponseMessage in chatResponse.Messages) { chatResponseMessage.AuthorName ??= agentName; + chatResponseMessage.MessageId ??= Guid.NewGuid().ToString("N"); + chatResponseMessage.CreatedAt ??= DateTimeOffset.UtcNow; } // Only notify the thread of new messages if the chatResponse was successful to avoid inconsistent message state in the thread. @@ -194,13 +196,17 @@ public sealed class ChatClientAgent : AIAgent throw; } + string? messageId = null; while (hasUpdates) { var update = responseUpdatesEnumerator.Current; if (update is not null) { - responseUpdates.Add(update); update.AuthorName ??= this.Name; + update.CreatedAt ??= DateTimeOffset.UtcNow; + update.MessageId ??= (messageId ??= Guid.NewGuid().ToString("N")); + + responseUpdates.Add(update); yield return new(update) { AgentId = this.Id }; } diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentThread.cs b/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentThread.cs index a469b1268c..ba6af1be5e 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentThread.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentThread.cs @@ -43,8 +43,7 @@ public class ChatClientAgentThread : AgentThread throw new ArgumentException("The serialized thread state must be a JSON object.", nameof(serializedThreadState)); } - var state = JsonSerializer.Deserialize( - serializedThreadState, + var state = serializedThreadState.Deserialize( AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ThreadState))) as ThreadState; this.AIContextProvider = aiContextProviderFactory?.Invoke(state?.AIContextProviderState ?? default, jsonSerializerOptions); @@ -57,12 +56,9 @@ public class ChatClientAgentThread : AgentThread return; } - this._messageStore = chatMessageStoreFactory?.Invoke(state?.StoreState ?? default, jsonSerializerOptions); - if (this._messageStore is null) - { - // If we didn't get a custom store, create an in-memory one. - this._messageStore = new InMemoryChatMessageStore(state?.StoreState ?? default, jsonSerializerOptions); - } + this._messageStore = + chatMessageStoreFactory?.Invoke(state?.StoreState ?? default, jsonSerializerOptions) ?? + new InMemoryChatMessageStore(state?.StoreState ?? default, jsonSerializerOptions); // default to an in-memory store } /// diff --git a/dotnet/tests/CosmosDB.IntegrationTests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests/CosmosActorStateStorageConcurrencyTests.cs b/dotnet/tests/CosmosDB.IntegrationTests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests/CosmosActorStateStorageConcurrencyTests.cs index 126aa8639a..f384af6b27 100644 --- a/dotnet/tests/CosmosDB.IntegrationTests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests/CosmosActorStateStorageConcurrencyTests.cs +++ b/dotnet/tests/CosmosDB.IntegrationTests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests/CosmosActorStateStorageConcurrencyTests.cs @@ -30,7 +30,7 @@ public class CosmosActorStateStorageConcurrencyTests var cancellationToken = cts.Token; await using var storage = new CosmosActorStateStorage(this._fixture.Container); - var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString()); + var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString("N")); const string Key = "testKey"; var value1 = JsonSerializer.SerializeToElement("value1"); @@ -69,7 +69,7 @@ public class CosmosActorStateStorageConcurrencyTests var cancellationToken = cts.Token; await using var storage = new CosmosActorStateStorage(this._fixture.Container); - var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString()); + var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString("N")); // Setup initial state var initialOperations = new List @@ -184,7 +184,7 @@ public class CosmosActorStateStorageConcurrencyTests var cancellationToken = cts.Token; await using var storage = new CosmosActorStateStorage(this._fixture.Container); - var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString()); + var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString("N")); const string Key = "testKey"; var value = JsonSerializer.SerializeToElement("testValue"); @@ -200,7 +200,7 @@ public class CosmosActorStateStorageConcurrencyTests Assert.NotEmpty(resultWithNullETag.ETag); // Clean up for next test - var uniqueActorId1 = new ActorId("TestActor", Guid.NewGuid().ToString()); + var uniqueActorId1 = new ActorId("TestActor", Guid.NewGuid().ToString("N")); // Act & Assert - Test empty eTag (should create new document) var resultWithEmptyETag = await storage.WriteStateAsync(uniqueActorId1, operations, string.Empty, cancellationToken); @@ -209,7 +209,7 @@ public class CosmosActorStateStorageConcurrencyTests Assert.NotEmpty(resultWithEmptyETag.ETag); // Clean up for next test - var uniqueActorId2 = new ActorId("TestActor", Guid.NewGuid().ToString()); + var uniqueActorId2 = new ActorId("TestActor", Guid.NewGuid().ToString("N")); // Act & Assert - Test "0" initial eTag (should create new document) var resultWithInitialETag = await storage.WriteStateAsync(uniqueActorId2, operations, "0", cancellationToken); @@ -255,7 +255,7 @@ public class CosmosActorStateStorageConcurrencyTests var cancellationToken = cts.Token; await using var storage = new CosmosActorStateStorage(this._fixture.Container); - var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString()); // Fresh actor + var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString("N")); // Fresh actor const string Key = "testKey"; var value = JsonSerializer.SerializeToElement("testValue"); @@ -305,7 +305,7 @@ public class CosmosActorStateStorageConcurrencyTests var cancellationToken = cts.Token; await using var storage = new CosmosActorStateStorage(this._fixture.Container); - var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString()); // Non-existent actor + var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString("N")); // Non-existent actor const string Key = "testKey"; var value = JsonSerializer.SerializeToElement("testValue"); diff --git a/dotnet/tests/CosmosDB.IntegrationTests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests/CosmosActorStateStorageListKeysTests.cs b/dotnet/tests/CosmosDB.IntegrationTests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests/CosmosActorStateStorageListKeysTests.cs index ef26b79bab..0f11a23e64 100644 --- a/dotnet/tests/CosmosDB.IntegrationTests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests/CosmosActorStateStorageListKeysTests.cs +++ b/dotnet/tests/CosmosDB.IntegrationTests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests/CosmosActorStateStorageListKeysTests.cs @@ -27,7 +27,7 @@ public class CosmosActorStateStorageListKeysTests var cancellationToken = cts.Token; await using var storage = new CosmosActorStateStorage(this._fixture.Container); - var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString()); + var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString("N")); const string PrefixKey1 = "prefix_key1"; const string PrefixKey2 = "prefix_key2"; @@ -70,7 +70,7 @@ public class CosmosActorStateStorageListKeysTests var cancellationToken = cts.Token; await using var storage = new CosmosActorStateStorage(this._fixture.Container); - var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString()); + var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString("N")); const string Key1 = "key1"; const string Key2 = "key2"; @@ -108,7 +108,7 @@ public class CosmosActorStateStorageListKeysTests var cancellationToken = cts.Token; await using var storage = new CosmosActorStateStorage(this._fixture.Container); - var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString()); + var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString("N")); // Act - List keys for actor with no state var readOperations = new List @@ -133,7 +133,7 @@ public class CosmosActorStateStorageListKeysTests var cancellationToken = cts.Token; await using var storage = new CosmosActorStateStorage(this._fixture.Container); - var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString()); + var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString("N")); const string Key1 = "key1"; const string Key2 = "key2"; @@ -174,7 +174,7 @@ public class CosmosActorStateStorageListKeysTests var cancellationToken = cts.Token; await using var storage = new CosmosActorStateStorage(this._fixture.Container); - var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString()); + var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString("N")); const string Key1 = "key1"; const string Key2 = "key2"; @@ -226,7 +226,7 @@ public class CosmosActorStateStorageListKeysTests var cancellationToken = cts.Token; await using var storage = new CosmosActorStateStorage(this._fixture.Container); - var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString()); + var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString("N")); // Create keys with different prefixes string[] userKeys = ["user_profile", "user_settings", "user_preferences"]; diff --git a/dotnet/tests/CosmosDB.IntegrationTests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests/CosmosActorStateStorageTests.cs b/dotnet/tests/CosmosDB.IntegrationTests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests/CosmosActorStateStorageTests.cs index 7f756e16c9..93c2b681c6 100644 --- a/dotnet/tests/CosmosDB.IntegrationTests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests/CosmosActorStateStorageTests.cs +++ b/dotnet/tests/CosmosDB.IntegrationTests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests/CosmosActorStateStorageTests.cs @@ -27,7 +27,7 @@ public class CosmosActorStateStorageTests var cancellationToken = cts.Token; await using var storage = new CosmosActorStateStorage(this._fixture.Container); - var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString()); + var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString("N")); const string Key = "testKey"; var value = JsonSerializer.SerializeToElement("testValue"); @@ -53,7 +53,7 @@ public class CosmosActorStateStorageTests var cancellationToken = cts.Token; await using var storage = new CosmosActorStateStorage(this._fixture.Container); - var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString()); + var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString("N")); const string Key1 = "key1"; const string Key2 = "key2"; @@ -155,7 +155,7 @@ public class CosmosActorStateStorageTests var cancellationToken = cts.Token; await using var storage = new CosmosActorStateStorage(this._fixture.Container); - var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString()); + var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString("N")); const string Key = "testKey"; var value = JsonSerializer.SerializeToElement("testValue"); @@ -197,8 +197,8 @@ public class CosmosActorStateStorageTests var cancellationToken = cts.Token; await using var storage = new CosmosActorStateStorage(this._fixture.Container); - var testActorId1 = new ActorId("TestActor1", Guid.NewGuid().ToString()); - var testActorId2 = new ActorId("TestActor2", Guid.NewGuid().ToString()); + var testActorId1 = new ActorId("TestActor1", Guid.NewGuid().ToString("N")); + var testActorId2 = new ActorId("TestActor2", Guid.NewGuid().ToString("N")); const string Key = "sharedKey"; var value1 = JsonSerializer.SerializeToElement("value1"); @@ -243,7 +243,7 @@ public class CosmosActorStateStorageTests using var cts = new CancellationTokenSource(s_defaultTimeout); var cancellationToken = cts.Token; await using var storage = new CosmosActorStateStorage(this._fixture.Container); - var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString()); + var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString("N")); var emptyOperations = new List(); // Act & Assert await Assert.ThrowsAsync(async () => await storage.WriteStateAsync(testActorId, emptyOperations, "0", cancellationToken)); @@ -257,7 +257,7 @@ public class CosmosActorStateStorageTests var cancellationToken = cts.Token; await using var storage = new CosmosActorStateStorage(this._fixture.Container); - var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString()); + var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString("N")); var readOperations = new List { @@ -282,7 +282,7 @@ public class CosmosActorStateStorageTests var cancellationToken = cts.Token; await using var storage = new CosmosActorStateStorage(this._fixture.Container); - var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString()); + var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString("N")); // Create a complex object with various types var complexObject = new @@ -353,7 +353,7 @@ public class CosmosActorStateStorageTests var cancellationToken = cts.Token; await using var storage = new CosmosActorStateStorage(this._fixture.Container); - var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString()); + var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString("N")); const string Key1 = "key1"; const string Key2 = "key2"; @@ -420,7 +420,7 @@ public class CosmosActorStateStorageTests var cancellationToken = cts.Token; await using var storage = new CosmosActorStateStorage(this._fixture.Container); - var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString()); + var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString("N")); // Test keys with special characters that need sanitization var specialKeys = new[] diff --git a/dotnet/tests/CosmosDB.IntegrationTests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests/LazyCosmosContainerTests.cs b/dotnet/tests/CosmosDB.IntegrationTests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests/LazyCosmosContainerTests.cs index 99e365c367..7baa513270 100644 --- a/dotnet/tests/CosmosDB.IntegrationTests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests/LazyCosmosContainerTests.cs +++ b/dotnet/tests/CosmosDB.IntegrationTests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests/LazyCosmosContainerTests.cs @@ -74,7 +74,7 @@ public class LazyCosmosContainerTests Assert.Equal(testContainerName, container.Id); // Verify the container can perform basic operations - var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString()); + var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString("N")); await using var storage = new CosmosActorStateStorage(lazyContainer); const string Key = "testKey"; @@ -219,7 +219,7 @@ public class LazyCosmosContainerTests { // Act - Create storage using the internal constructor (like DI would) await using var storage = new CosmosActorStateStorage(lazyContainer); - var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString()); + var testActorId = new ActorId("TestActor", Guid.NewGuid().ToString("N")); const string Key = "testKey"; var value = JsonSerializer.SerializeToElement("testValue"); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/AgentWorkflowBuilderTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/AgentWorkflowBuilderTests.cs index 62e2b8a4af..fcc42a8184 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/AgentWorkflowBuilderTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/AgentWorkflowBuilderTests.cs @@ -6,7 +6,6 @@ using System.Linq; using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; -using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -35,43 +34,107 @@ public class AgentWorkflowBuilderTests [Fact] public void BuildHandoffs_InvalidArguments_Throws() { - Assert.Throws("initialAgent", () => AgentWorkflowBuilder.StartHandoffWith(null!)); + Assert.Throws("initialAgent", () => AgentWorkflowBuilder.CreateHandoffBuilderWith(null!)); var agent = new DoubleEchoAgent("agent"); - var handoffs = AgentWorkflowBuilder.StartHandoffWith(agent); + var handoffs = AgentWorkflowBuilder.CreateHandoffBuilderWith(agent); Assert.NotNull(handoffs); Assert.Throws("from", () => handoffs.WithHandoff(null!, new DoubleEchoAgent("a2"))); - Assert.Throws("to", () => handoffs.WithHandoff(new DoubleEchoAgent("a2"), (AIAgent)null!)); Assert.Throws("to", () => handoffs.WithHandoff(new DoubleEchoAgent("a2"), null!)); - Assert.Throws("to", () => handoffs.WithHandoff(new DoubleEchoAgent("a2"), [null!])); + + Assert.Throws("from", () => handoffs.WithHandoffs(null!, new DoubleEchoAgent("a2"))); + Assert.Throws("from", () => handoffs.WithHandoffs([null!], new DoubleEchoAgent("a2"))); + Assert.Throws("to", () => handoffs.WithHandoffs(new DoubleEchoAgent("a2"), null!)); + Assert.Throws("to", () => handoffs.WithHandoffs(new DoubleEchoAgent("a2"), [null!])); var noDescriptionAgent = new ChatClientAgent(new MockChatClient(delegate { return new(); })); Assert.Throws("to", () => handoffs.WithHandoff(agent, noDescriptionAgent)); } [Fact] - public async Task BuildSequential_AgentsRunInOrderAsync() + public void BuildGroupChat_InvalidArguments_Throws() + { + Assert.Throws("managerFactory", () => AgentWorkflowBuilder.CreateGroupChatBuilderWith(null!)); + + var groupChat = AgentWorkflowBuilder.CreateGroupChatBuilderWith(_ => new AgentWorkflowBuilder.RoundRobinGroupChatManager([new DoubleEchoAgent("a1")])); + Assert.NotNull(groupChat); + Assert.Throws("agents", () => groupChat.AddParticipants(null!)); + Assert.Throws("agents", () => groupChat.AddParticipants([null!])); + Assert.Throws("agents", () => groupChat.AddParticipants(new DoubleEchoAgent("a1"), null!)); + + Assert.Throws("agents", () => new AgentWorkflowBuilder.RoundRobinGroupChatManager(null!)); + } + + [Fact] + public void GroupChatManager_MaximumIterationCount_Invalid_Throws() + { + var manager = new AgentWorkflowBuilder.RoundRobinGroupChatManager([new DoubleEchoAgent("a1")]); + + const int DefaultMaxIterations = 40; + Assert.Equal(DefaultMaxIterations, manager.MaximumIterationCount); + Assert.Throws("value", () => manager.MaximumIterationCount = 0); + Assert.Throws("value", () => manager.MaximumIterationCount = -1); + Assert.Equal(DefaultMaxIterations, manager.MaximumIterationCount); + + manager.MaximumIterationCount = 30; + Assert.Equal(30, manager.MaximumIterationCount); + + manager.MaximumIterationCount = 1; + Assert.Equal(1, manager.MaximumIterationCount); + + manager.MaximumIterationCount = int.MaxValue; + Assert.Equal(int.MaxValue, manager.MaximumIterationCount); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + public async Task BuildSequential_AgentsRunInOrderAsync(int numAgents) { var workflow = AgentWorkflowBuilder.BuildSequential( - new DoubleEchoAgent("agent1"), - new DoubleEchoAgent("agent2"), - new DoubleEchoAgent("agent3")); + from i in Enumerable.Range(1, numAgents) + select new DoubleEchoAgent($"agent{i}")); for (int iter = 0; iter < 3; iter++) { - (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); - - const string Expected = "agent1abcabcagent2agent1abcabcagent1abcabcagent3agent2agent1abcabcagent1abcabcagent2agent1abcabcagent1abcabc"; - Assert.Equal(Expected, updateText); + const string UserInput = "abc"; + (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); Assert.NotNull(result); - Assert.NotNull(Assert.Single(result)); + Assert.Equal(numAgents + 1, result.Count); + + Assert.Equal(ChatRole.User, result[0].Role); + Assert.Null(result[0].AuthorName); + Assert.Equal(UserInput, result[0].Text); + + string[] texts = new string[numAgents + 1]; + texts[0] = UserInput; + string expectedTotal = string.Empty; + for (int i = 1; i < numAgents + 1; i++) + { + string id = $"agent{((i - 1) % numAgents) + 1}"; + texts[i] = $"{id}{Double(string.Concat(texts.Take(i)))}"; + Assert.Equal(ChatRole.Assistant, result[i].Role); + Assert.Equal(id, result[i].AuthorName); + Assert.Equal(texts[i], result[i].Text); + expectedTotal += texts[i]; + } + + Assert.Equal(expectedTotal, updateText); + Assert.Equal(UserInput + expectedTotal, string.Concat(result)); + + static string Double(string s) => s + s; } } private class DoubleEchoAgent(string name) : AIAgent { + public override string Name => name; + public override AgentThread GetNewThread() => new DoubleEchoAgentThread(); @@ -85,14 +148,13 @@ public class AgentWorkflowBuilderTests public override async IAsyncEnumerable RunStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - string id = Guid.NewGuid().ToString("N"); - var contents = messages.SelectMany(m => m.Contents).ToList(); - await Task.Yield(); - yield return new AgentRunResponseUpdate(ChatRole.Assistant, name) { MessageId = id }; - yield return new AgentRunResponseUpdate(ChatRole.Assistant, contents) { MessageId = id }; - yield return new AgentRunResponseUpdate(ChatRole.Assistant, contents) { MessageId = id }; + var contents = messages.SelectMany(m => m.Contents).ToList(); + string id = Guid.NewGuid().ToString("N"); + yield return new AgentRunResponseUpdate(ChatRole.Assistant, this.Name) { AuthorName = this.Name, MessageId = id }; + yield return new AgentRunResponseUpdate(ChatRole.Assistant, contents) { AuthorName = this.Name, MessageId = id }; + yield return new AgentRunResponseUpdate(ChatRole.Assistant, contents) { AuthorName = this.Name, MessageId = id }; } } @@ -116,13 +178,13 @@ public class AgentWorkflowBuilderTests remaining.Value = 2; (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); - - Assert.Single(Regex.Matches(updateText, "agent1")); - Assert.Single(Regex.Matches(updateText, "agent2")); + Assert.NotEmpty(updateText); Assert.NotNull(result); // TODO: https://github.com/microsoft/agent-framework/issues/784 // These asserts are flaky until we guarantee message delivery order. + //Assert.Single(Regex.Matches(updateText, "agent1")); + //Assert.Single(Regex.Matches(updateText, "agent2")); //Assert.Equal(4, Regex.Matches(updateText, "abc").Count); //Assert.Equal(2, result.Count); } @@ -136,18 +198,11 @@ public class AgentWorkflowBuilderTests ChatMessage message = Assert.Single(messages); Assert.Equal("abc", Assert.IsType(Assert.Single(message.Contents)).Text); - string? endFunctionName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("end", StringComparison.Ordinal))?.Name; - Assert.NotNull(endFunctionName); - - return new(new ChatMessage(ChatRole.Assistant, - [ - new TextContent("Hello from agent1"), - new FunctionCallContent("call12345", endFunctionName), - ])); + return new(new ChatMessage(ChatRole.Assistant, "Hello from agent1")); })); var workflow = - AgentWorkflowBuilder.StartHandoffWith(initialAgent) + AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) .WithHandoff(initialAgent, new ChatClientAgent(new MockChatClient(delegate { Assert.Fail("Should never be invoked."); @@ -184,19 +239,12 @@ public class AgentWorkflowBuilderTests }), name: "initialAgent"); var nextAgent = new ChatClientAgent(new MockChatClient((messages, options) => - { - string? endFunctionName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("end", StringComparison.Ordinal))?.Name; - Assert.NotNull(endFunctionName); - - return new(new ChatMessage(ChatRole.Assistant, - [ - new TextContent("Hello from agent2"), - new FunctionCallContent("call2", endFunctionName), - ])); - }), name: "nextAgent", description: "The second agent"); + new(new ChatMessage(ChatRole.Assistant, "Hello from agent2"))), + name: "nextAgent", + description: "The second agent"); var workflow = - AgentWorkflowBuilder.StartHandoffWith(initialAgent) + AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) .WithHandoff(initialAgent, nextAgent) .Build(); @@ -247,19 +295,12 @@ public class AgentWorkflowBuilderTests }), name: "secondAgent", description: "The second agent"); var thirdAgent = new ChatClientAgent(new MockChatClient((messages, options) => - { - string? endFunctionName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("end", StringComparison.Ordinal))?.Name; - Assert.NotNull(endFunctionName); - - return new(new ChatMessage(ChatRole.Assistant, - [ - new TextContent("Hello from agent3"), - new FunctionCallContent("call3", endFunctionName), - ])); - }), name: "thirdAgent", description: "The third / final agent"); + new(new ChatMessage(ChatRole.Assistant, "Hello from agent3"))), + name: "thirdAgent", + description: "The third / final agent"); var workflow = - AgentWorkflowBuilder.StartHandoffWith(initialAgent) + AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) .WithHandoff(initialAgent, secondAgent) .WithHandoff(secondAgent, thirdAgent) .Build(); @@ -294,6 +335,52 @@ public class AgentWorkflowBuilderTests Assert.Contains("thirdAgent", result[5].AuthorName); } + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + public async Task BuildGroupChat_AgentsRunInOrderAsync(int maxIterations) + { + const int NumAgents = 3; + var workflow = AgentWorkflowBuilder.CreateGroupChatBuilderWith(agents => new AgentWorkflowBuilder.RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxIterations }) + .AddParticipants(new DoubleEchoAgent("agent1"), new DoubleEchoAgent("agent2")) + .AddParticipants(new DoubleEchoAgent("agent3")) + .Build(); + + for (int iter = 0; iter < 3; iter++) + { + const string UserInput = "abc"; + (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); + + Assert.NotNull(result); + Assert.Equal(maxIterations + 1, result.Count); + + Assert.Equal(ChatRole.User, result[0].Role); + Assert.Null(result[0].AuthorName); + Assert.Equal(UserInput, result[0].Text); + + string[] texts = new string[maxIterations + 1]; + texts[0] = UserInput; + string expectedTotal = string.Empty; + for (int i = 1; i < maxIterations + 1; i++) + { + string id = $"agent{((i - 1) % NumAgents) + 1}"; + texts[i] = $"{id}{Double(string.Concat(texts.Take(i)))}"; + Assert.Equal(ChatRole.Assistant, result[i].Role); + Assert.Equal(id, result[i].AuthorName); + Assert.Equal(texts[i], result[i].Text); + expectedTotal += texts[i]; + } + + Assert.Equal(expectedTotal, updateText); + Assert.Equal(UserInput + expectedTotal, string.Concat(result)); + + static string Double(string s) => s + s; + } + } + private static async Task<(string UpdateText, List? Result)> RunWorkflowAsync( Workflow> workflow, List input) { diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ChatMessageBuilder.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ChatMessageBuilder.cs index 1a1183f240..87974253f1 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ChatMessageBuilder.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ChatMessageBuilder.cs @@ -30,7 +30,7 @@ internal static class TextMessageStreamingExtensions new() { Role = ChatRole.Assistant, - CreatedAt = createdAt ?? DateTimeOffset.Now, + CreatedAt = createdAt ?? DateTimeOffset.UtcNow, MessageId = messageId ?? Guid.NewGuid().ToString("N"), ResponseId = responseId, AgentId = agentId, @@ -50,7 +50,7 @@ internal static class TextMessageStreamingExtensions new(ChatRole.Assistant, contents is List contentsList ? contentsList : contents.ToList()) { AuthorName = authorName, - CreatedAt = createdAt ?? DateTimeOffset.Now, + CreatedAt = createdAt ?? DateTimeOffset.UtcNow, MessageId = messageId ?? Guid.NewGuid().ToString("N"), RawRepresentation = rawRepresentation, }; @@ -77,7 +77,7 @@ internal static class TextMessageStreamingExtensions AuthorName = authorName, MessageId = Guid.NewGuid().ToString("N"), RawRepresentation = text, - CreatedAt = DateTimeOffset.Now, + CreatedAt = DateTimeOffset.UtcNow, }; } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/06_GroupChat_Workflow.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/06_GroupChat_Workflow.cs index b57e072ea3..6684c09c0f 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/06_GroupChat_Workflow.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/06_GroupChat_Workflow.cs @@ -17,16 +17,11 @@ namespace Microsoft.Agents.Workflows.Sample; internal static class Step6EntryPoint { - public static Workflow> CreateWorkflow(int maxTurns) - { - GroupChatBuilder builder = - GroupChatBuilder.Create - (options => options.MaxTurns = maxTurns) - .AddParticipant(new HelloAgent(), shouldEmitEvents: true) - .AddParticipant(new EchoAgent(), shouldEmitEvents: true); - - return builder.ReduceToWorkflow(); - } + public static Workflow> CreateWorkflow(int maxTurns) => + AgentWorkflowBuilder + .CreateGroupChatBuilderWith(agents => new AgentWorkflowBuilder.RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxTurns }) + .AddParticipants(new HelloAgent(), new EchoAgent()) + .Build(); public static async ValueTask RunAsync(TextWriter writer, int maxSteps = 2) { @@ -53,39 +48,6 @@ internal static class Step6EntryPoint } } } - - private sealed class RoundRobinGroupChatManagerOptions : GroupChatManagerOptions - { - public int? MaxTurns { get; set; } - } - - private sealed class RoundRobinGroupChatManager() : GroupChatManager - { - public int TurnCount { get; private set; } - public int? MaxTurns { get; private set; } - - protected internal override void Configure(RoundRobinGroupChatManagerOptions options) - { - base.Configure(options); - - this.MaxTurns = options.MaxTurns; - } - - public override int? GetNextTurnExecutor(GroupChatHistory history) - { - if (this.ParticipantIds.Length == 0) - { - throw new InvalidOperationException("No participants in the group chat."); - } - - if (this.TurnCount >= this.MaxTurns) - { - return null; - } - - return this.TurnCount++ % this.ParticipantIds.Length; - } - } } internal sealed class HelloAgent(string id = nameof(HelloAgent)) : AIAgent @@ -175,184 +137,3 @@ internal sealed class EchoAgent(string id = nameof(EchoAgent)) : AIAgent } internal sealed class EchoAgentThread() : InMemoryAgentThread(); - -internal sealed class GroupChatHistory -{ - private readonly List _messages = []; - private int _bookmark; - - public void AddMessage(ChatMessage message) => - this._messages.Add(message); - - public void AddMessages(IEnumerable messages) => - this._messages.AddRange(messages); - - public void UpdateBookmark() => - this._bookmark = this._messages.Count; - - public IReadOnlyList FullHistory => this._messages.AsReadOnly(); - public IEnumerable NewMessagesThisTurn => this._messages.Skip(this._bookmark); -} - -internal class GroupChatManagerOptions; - -internal abstract class GroupChatManager -{ - public string[] ParticipantIds { get; internal init; } = []; - - public abstract int? GetNextTurnExecutor(GroupChatHistory history); -} - -internal abstract class GroupChatManager : GroupChatManager where TOptions : GroupChatManagerOptions, new() -{ - protected internal virtual void Configure(TOptions options) { } -} - -internal sealed class GroupChatBuilder -{ - private readonly List _participants = []; - private readonly List _shouldEmitEvents = []; - private readonly Func _managerFactory; - - private GroupChatBuilder(Func managerFactory) - { - this._managerFactory = managerFactory; - } - - public static GroupChatBuilder Create() where TManager : GroupChatManager, new() => - new(participantIds => new TManager() { ParticipantIds = participantIds }); - - public static GroupChatBuilder Create(Action configure) - where TManager : GroupChatManager, new() - where TOptions : GroupChatManagerOptions, new() - { - TOptions options = new(); - configure(options); - - return new GroupChatBuilder(participantIds => - { - TManager manager = new() { ParticipantIds = participantIds }; - manager.Configure(options); - return manager; - }); - } - - public GroupChatBuilder AddParticipant(ExecutorIsh executor, bool shouldEmitEvents = false) - { - this._participants.Add(executor); - this._shouldEmitEvents.Add(shouldEmitEvents); - - return this; - } - - public GroupChatBuilder AddParticipants(params ExecutorIsh[] executors) - { - this._participants.AddRange(executors); - return this; - } - - public Workflow> ReduceToWorkflow() - { - string[] participantIds = this._participants.Select(identified => identified.Id).ToArray(); - GroupChatHost host = new(this._shouldEmitEvents.ToArray(), this._managerFactory(participantIds)); - - WorkflowBuilder builder = new WorkflowBuilder(host) - .AddFanOutEdge(host, targets: this._participants.ToArray()); - - foreach (ExecutorIsh participant in this._participants) - { - builder.AddEdge(participant, host); - } - - return builder.Build>(); - - //bool IsMessageType(object? message) => message is ChatMessage || message is IEnumerable; - } - - private sealed class TurnAssignedEvent(string executorId, string nextSpeakerId) : ExecutorEvent(executorId, data: nextSpeakerId); - - private sealed class GroupChatHost : Executor - { - private readonly bool[] _shouldEmitEvents; - private readonly GroupChatManager _manager; - private readonly bool _autoStartConversation; - - private readonly GroupChatHistory _history = new(); - - public GroupChatHost(bool[] shouldEmitEvents, GroupChatManager manager, bool autoStartConversation = false) : base(nameof(GroupChatHost)) - { - this._shouldEmitEvents = shouldEmitEvents; - this._manager = manager ?? throw new ArgumentNullException(nameof(manager)); - this._autoStartConversation = autoStartConversation; - } - - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => - routeBuilder.AddHandler>(this.HandleChatMessagesAsync) - .AddHandler(this.HandleChatMessageAsync) - .AddHandler(this.AssignNextTurnAsync); - - private async Task TryAutoStartConversationAsync(IWorkflowContext context) - { - if (this._autoStartConversation && this.TryEnterConversation()) - { - await this.AssignNextTurnAsync(new TurnToken(emitEvents: false), context).ConfigureAwait(false); - } - } - - private async ValueTask HandleChatMessagesAsync(List initialMessages, IWorkflowContext context) - { - this._history.AddMessages(initialMessages); - - await context.SendMessageAsync(initialMessages).ConfigureAwait(false); - await this.TryAutoStartConversationAsync(context).ConfigureAwait(false); - } - - private async ValueTask HandleChatMessageAsync(ChatMessage message, IWorkflowContext context) - { - // First, add the message to the history, then forward to all executors - this._history.AddMessage(message); - - await context.SendMessageAsync(message).ConfigureAwait(false); - await this.TryAutoStartConversationAsync(context).ConfigureAwait(false); - } - - private int _inConversationFlag; - - /// - /// Atomically switches to "in conversation" state if not already in that state. - /// - /// if the state was changed, otherwise. - private bool TryEnterConversation() => - Interlocked.CompareExchange(ref this._inConversationFlag, 1, 0) == 0; - - private bool _shouldHostEmitEvents; - private async ValueTask AssignNextTurnAsync(TurnToken token, IWorkflowContext context) - { - if (this.TryEnterConversation()) - { - // Capture the initial turn token's EmitEvents setting - this._shouldHostEmitEvents = token.EmitEvents ?? false; - } - - int? nextSpeakerIndex = this._manager.GetNextTurnExecutor(this._history); - if (nextSpeakerIndex is null) - { - await context.AddEventAsync(new WorkflowCompletedEvent()) - .ConfigureAwait(false); - - return; - } - - string nextSpeakerId = this._manager.ParticipantIds[nextSpeakerIndex.Value]; - - if (this._shouldHostEmitEvents) - { - await context.AddEventAsync(new TurnAssignedEvent(this.Id, nextSpeakerId)) - .ConfigureAwait(false); - } - - await context.SendMessageAsync(new TurnToken(this._shouldEmitEvents[nextSpeakerIndex.Value]), nextSpeakerId) - .ConfigureAwait(false); - } - } -} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SpecializedExecutorSmokeTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SpecializedExecutorSmokeTests.cs index ac43f8874a..ef084f2578 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SpecializedExecutorSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SpecializedExecutorSmokeTests.cs @@ -43,7 +43,7 @@ public class SpecializedExecutorSmokeTests { MessageId = Guid.NewGuid().ToString("N"), RawRepresentation = text, - CreatedAt = DateTime.Now, + CreatedAt = DateTime.UtcNow, }; } diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/AgentThreadTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/AgentThreadTests.cs index e424ca2d6b..e0261bebb9 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/AgentThreadTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/AgentThreadTests.cs @@ -2,8 +2,6 @@ using System; using System.Collections.Generic; -using System.Text.Json; -using System.Threading; using System.Threading.Tasks; #pragma warning disable CA1861 // Avoid constant arrays as arguments @@ -148,12 +146,5 @@ public class AgentThreadTests #endregion - private sealed class TestAgentThread : AgentThread - { - protected internal override Task MessagesReceivedAsync(IEnumerable newMessages, CancellationToken cancellationToken = default) - => base.MessagesReceivedAsync(newMessages, cancellationToken); - - public override Task SerializeAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) - => base.SerializeAsync(jsonSerializerOptions, cancellationToken); - } + private sealed class TestAgentThread : AgentThread; } diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/ChatMessageStoreTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/ChatMessageStoreTests.cs index 65b1cf00df..023790a8f3 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/ChatMessageStoreTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/ChatMessageStoreTests.cs @@ -78,7 +78,7 @@ public class ChatMessageStoreTests private sealed class TestChatMessageStore : ChatMessageStore { public override Task> GetMessagesAsync(CancellationToken cancellationToken = default) - => Task.FromResult>(Array.Empty()); + => Task.FromResult>([]); public override Task AddMessagesAsync(IEnumerable messages, CancellationToken cancellationToken = default) => Task.CompletedTask; diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/DelegatingAIAgentTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/DelegatingAIAgentTests.cs index 85c26adb59..b0beb71706 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/DelegatingAIAgentTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/DelegatingAIAgentTests.cs @@ -300,9 +300,7 @@ public class DelegatingAIAgentTests public new AIAgent InnerAgent => base.InnerAgent; } - private sealed class TestAgentThread : AgentThread - { - } + private sealed class TestAgentThread : AgentThread; #endregion } diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/InMemoryAgentThreadTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/InMemoryAgentThreadTests.cs index 64729684c4..50b7578fa1 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/InMemoryAgentThreadTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/InMemoryAgentThreadTests.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; -using System.Threading; using System.Threading.Tasks; namespace Microsoft.Extensions.AI.Agents.Abstractions.UnitTests; @@ -31,8 +30,7 @@ public class InMemoryAgentThreadTests public void Constructor_WithMessageStore_SetsProperty() { // Arrange - var store = new InMemoryChatMessageStore(); - store.Add(new ChatMessage(ChatRole.User, "Hello")); + InMemoryChatMessageStore store = [new(ChatRole.User, "Hello")]; // Act var thread = new TestInMemoryAgentThread(store); @@ -62,8 +60,7 @@ public class InMemoryAgentThreadTests public async Task Constructor_WithSerializedState_SetsPropertyAsync() { // Arrange - var store = new InMemoryChatMessageStore(); - store.Add(new ChatMessage(ChatRole.User, "TestMsg")); + InMemoryChatMessageStore store = [new(ChatRole.User, "TestMsg")]; var storeState = await store.SerializeStateAsync(); var json = JsonSerializer.SerializeToElement(new { storeState }); @@ -94,9 +91,7 @@ public class InMemoryAgentThreadTests public async Task SerializeAsync_ReturnsCorrectJson_WhenMessagesExistAsync() { // Arrange - var store = new InMemoryChatMessageStore(); - store.Add(new ChatMessage(ChatRole.User, "TestContent")); - var thread = new TestInMemoryAgentThread(store); + var thread = new TestInMemoryAgentThread([new(ChatRole.User, "TestContent")]); // Act var json = await thread.SerializeAsync(); @@ -150,11 +145,10 @@ public class InMemoryAgentThreadTests // Sealed test subclass to expose protected members for testing private sealed class TestInMemoryAgentThread : InMemoryAgentThread { - public TestInMemoryAgentThread() : base() { } + public TestInMemoryAgentThread() { } public TestInMemoryAgentThread(InMemoryChatMessageStore? store) : base(store) { } public TestInMemoryAgentThread(IEnumerable messages) : base(messages) { } public TestInMemoryAgentThread(JsonElement serializedThreadState) : base(serializedThreadState) { } public InMemoryChatMessageStore GetMessageStore() => this.MessageStore; - public override Task SerializeAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => base.SerializeAsync(jsonSerializerOptions, cancellationToken); } } diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/ServiceIdAgentThreadTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/ServiceIdAgentThreadTests.cs index 4a5e28a41e..6fa011a70d 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/ServiceIdAgentThreadTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/ServiceIdAgentThreadTests.cs @@ -2,7 +2,6 @@ using System; using System.Text.Json; -using System.Threading; using System.Threading.Tasks; namespace Microsoft.Extensions.AI.Agents.Abstractions.UnitTests; @@ -108,10 +107,9 @@ public class ServiceIdAgentThreadTests // Sealed test subclass to expose protected members for testing private sealed class TestServiceIdAgentThread : ServiceIdAgentThread { - public TestServiceIdAgentThread() : base() { } + public TestServiceIdAgentThread() { } public TestServiceIdAgentThread(string serviceThreadId) : base(serviceThreadId) { } public TestServiceIdAgentThread(JsonElement serializedThreadState) : base(serializedThreadState) { } public string? GetServiceThreadId() => this.ServiceThreadId; - public override Task SerializeAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => base.SerializeAsync(jsonSerializerOptions, cancellationToken); } } diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentActorTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentActorTests.cs index fb1266ea9a..82a654b9a4 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentActorTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentActorTests.cs @@ -104,8 +104,10 @@ public class AgentActorTests var threadJson = JsonSerializer.SerializeToElement(new { conversationId = "expected-thread-id" }); var mockThread = new Mock(); - var testAgent = new TestAgent(); - testAgent.ThreadForCreate = mockThread.Object; + TestAgent testAgent = new() + { + ThreadForCreate = mockThread.Object + }; var mockContext = new Mock(); var actorId = new ActorId("TestAgent", "test-instance"); diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentThreadTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentThreadTests.cs index 736f75e06e..c7c15fb81f 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentThreadTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentThreadTests.cs @@ -239,8 +239,7 @@ public class ChatClientAgentThreadTests public async Task VerifyThreadSerializationWithMessagesAsync() { // Arrange - var store = new InMemoryChatMessageStore(); - store.Add(new ChatMessage(ChatRole.User, "TestContent") { AuthorName = "TestAuthor" }); + InMemoryChatMessageStore store = [new(ChatRole.User, "TestContent") { AuthorName = "TestAuthor" }]; var thread = new ChatClientAgentThread { MessageStore = store }; // Act @@ -273,13 +272,15 @@ public class ChatClientAgentThreadTests { // Arrange Mock mockProvider = new(); - var providerStateElement = JsonSerializer.SerializeToElement(new[] { "CP1" }, TestJsonSerializerContext.Default.StringArray); + var providerStateElement = JsonSerializer.SerializeToElement(["CP1"], TestJsonSerializerContext.Default.StringArray); mockProvider .Setup(m => m.SerializeAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(providerStateElement); - var thread = new ChatClientAgentThread(); - thread.AIContextProvider = mockProvider.Object; + var thread = new ChatClientAgentThread + { + AIContextProvider = mockProvider.Object + }; // Act var json = await thread.SerializeAsync(); diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/OpenTelemetryAgentTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/OpenTelemetryAgentTests.cs index 0229aea1f8..f0a4a2b360 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/OpenTelemetryAgentTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/OpenTelemetryAgentTests.cs @@ -23,7 +23,7 @@ public class OpenTelemetryAgentTests public async Task RunAsync_ExpectedTelemetryData_CollectedAsync(bool withError) { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) @@ -91,7 +91,7 @@ public class OpenTelemetryAgentTests public async Task RunStreamingAsync_ExpectedTelemetryData_CollectedAsync(bool withError) { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) @@ -165,7 +165,7 @@ public class OpenTelemetryAgentTests public async Task RunAsync_WithChatClientAgent_IncludesInstructionsAsync() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) @@ -204,7 +204,7 @@ public class OpenTelemetryAgentTests public async Task RunAsync_WithChatClientAgent_WithMetadata_UsesProviderNameAsync() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) @@ -248,7 +248,7 @@ public class OpenTelemetryAgentTests public async Task RunAsync_WithNonChatClientAgent_UsesDefaultSystemAsync() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) @@ -279,7 +279,7 @@ public class OpenTelemetryAgentTests public async Task RunAsync_WithChatClientAgent_WithDifferentProviders_UsesCorrectSystemAsync(string providerName) { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) @@ -321,7 +321,7 @@ public class OpenTelemetryAgentTests public async Task RunStreamingAsync_WithChatClientAgent_WithMetadata_UsesProviderNameAsync() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) @@ -372,7 +372,7 @@ public class OpenTelemetryAgentTests public async Task RunAsync_WithThreadId_IncludesThreadIdAsync() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) @@ -471,7 +471,7 @@ public class OpenTelemetryAgentTests public async Task WithOpenTelemetry_EnableSensitiveDataParameter_SetsPropertyCorrectlyAsync(bool enableSensitiveData) { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) @@ -592,7 +592,7 @@ public class OpenTelemetryAgentTests public async Task RunAsync_WithILogger_LogsEventsCorrectlyAsync() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var mockLogger = new Mock(); // Setup the logger to return true for IsEnabled to ensure logging occurs @@ -658,7 +658,7 @@ public class OpenTelemetryAgentTests public async Task RunAsync_WithOpenTelemetryChatClientAgent_DoesNotDuplicateLogsAsync() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) @@ -728,7 +728,7 @@ public class OpenTelemetryAgentTests public async Task RunAsync_WithRegularChatClientAgent_LogsCorrectlyAsync() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) @@ -793,7 +793,7 @@ public class OpenTelemetryAgentTests public void Constructor_WithOpenTelemetryChatClient_InheritsEnableSensitiveDataSetting() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var mockChatClient = new Mock(); // Setup ChatClientMetadata @@ -1097,7 +1097,7 @@ public class OpenTelemetryAgentTests public async Task RunAsync_WithChatClientAgent_ProviderNameReflectedInTelemetryAsync(string providerName) { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) @@ -1146,7 +1146,7 @@ public class OpenTelemetryAgentTests public async Task RunAsync_WithChatClientAgent_NullMetadata_DefaultsToMEAIAsync() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) @@ -1195,7 +1195,7 @@ public class OpenTelemetryAgentTests public async Task RunAsync_WithCustomAgent_CustomMetadata_ReflectedInTelemetryAsync() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) @@ -1242,7 +1242,7 @@ public class OpenTelemetryAgentTests public async Task RunAsync_WithCustomAgent_NoMetadata_DefaultsToMEAIAsync() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) @@ -1288,7 +1288,7 @@ public class OpenTelemetryAgentTests public async Task RunStreamingAsync_WithChatClientAgent_ProviderNameReflectedInTelemetryAsync(string providerName) { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) @@ -1346,7 +1346,7 @@ public class OpenTelemetryAgentTests public async Task RunAsync_MultipleCallsWithSameAgent_ConsistentProviderNameAsync() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) @@ -1567,7 +1567,7 @@ public class OpenTelemetryAgentTests public async Task RunAsync_WithNullResponseId_HandlesGracefullyAsync() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) @@ -1608,7 +1608,7 @@ public class OpenTelemetryAgentTests public async Task RunAsync_WithEmptyAgentName_UsesOperationNameOnlyAsync() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) @@ -1642,7 +1642,7 @@ public class OpenTelemetryAgentTests public async Task RunStreamingAsync_WithPartialUpdates_CombinesCorrectlyAsync() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) @@ -1733,7 +1733,7 @@ public class OpenTelemetryAgentTests public async Task RunAsync_WithMetricsEnabled_RecordsMetricsAsync() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); var exportedMetrics = new List(); @@ -1777,7 +1777,7 @@ public class OpenTelemetryAgentTests public async Task RunAsync_WithMetricsEnabledAndError_RecordsErrorMetricsAsync() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); var exportedMetrics = new List(); @@ -1817,7 +1817,7 @@ public class OpenTelemetryAgentTests public async Task RunStreamingAsync_WithMetricsEnabled_RecordsMetricsAsync() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); var exportedMetrics = new List(); @@ -1866,7 +1866,7 @@ public class OpenTelemetryAgentTests public async Task RunAsync_WithNullUsage_SkipsTokenMetricsAsync() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var exportedMetrics = new List(); using var meterProvider = OpenTelemetry.Sdk.CreateMeterProviderBuilder() @@ -1914,7 +1914,7 @@ public class OpenTelemetryAgentTests public async Task RunAsync_WithMetricsDisabled_SkipsMetricRecordingAsync() { // Arrange - No meter provider, so metrics are disabled - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() @@ -1945,7 +1945,7 @@ public class OpenTelemetryAgentTests public async Task RunAsync_WithPartialTokenUsage_RecordsAvailableTokensAsync() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var exportedMetrics = new List(); using var meterProvider = OpenTelemetry.Sdk.CreateMeterProviderBuilder() @@ -1993,7 +1993,7 @@ public class OpenTelemetryAgentTests public async Task RunAsync_WithNullDescription_SkipsDescriptionAttributeAsync() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) @@ -2034,7 +2034,7 @@ public class OpenTelemetryAgentTests public async Task RunAsync_WithAssistantMessage_LogsAssistantEventAsync() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) @@ -2084,7 +2084,7 @@ public class OpenTelemetryAgentTests public async Task RunAsync_WithToolMessage_LogsToolEventAsync() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) @@ -2134,7 +2134,7 @@ public class OpenTelemetryAgentTests public async Task RunAsync_WithToolMessageAndSensitiveData_LogsToolEventWithContentAsync() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) @@ -2197,7 +2197,7 @@ public class OpenTelemetryAgentTests public async Task RunAsync_WithFunctionCallAndSensitiveDataEnabled_LogsWithSensitiveContentAsync() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) @@ -2285,7 +2285,7 @@ public class OpenTelemetryAgentTests public async Task RunAsync_WithToolMessageAndSensitiveDataDisabled_LogsToolEventWithoutContentAsync() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) @@ -2350,7 +2350,7 @@ public class OpenTelemetryAgentTests public async Task RunAsync_WithFunctionCallAndSensitiveDataDisabled_LogsWithoutSensitiveContentAsync() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) @@ -2431,7 +2431,7 @@ public class OpenTelemetryAgentTests public async Task RunAsync_LoggerNotEnabled_DoesNotLogChatResponseAsync() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) @@ -2482,7 +2482,7 @@ public class OpenTelemetryAgentTests public async Task RunAsync_WithResponseMessages_LogsChoiceEventsAsync() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) @@ -2546,7 +2546,7 @@ public class OpenTelemetryAgentTests public async Task RunAsync_WithSingleResponseMessage_LogsChoiceEventAsync() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) @@ -2602,7 +2602,7 @@ public class OpenTelemetryAgentTests public void JsonSerializerOptions_GetterReturnsSetValue() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var mockLogger = new Mock(); var mockAgent = new Mock(); @@ -2617,7 +2617,7 @@ public class OpenTelemetryAgentTests public void JsonSerializerOptions_SetterUpdatesValue() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var mockLogger = new Mock(); var mockAgent = new Mock(); @@ -2640,7 +2640,7 @@ public class OpenTelemetryAgentTests public void JsonSerializerOptions_SetterThrowsOnNull() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var mockLogger = new Mock(); var mockAgent = new Mock(); @@ -2654,7 +2654,7 @@ public class OpenTelemetryAgentTests public async Task RunAsync_WithCustomJsonSerializerOptions_UsesCustomOptionsForSerializationAsync() { // Arrange - var sourceName = Guid.NewGuid().ToString(); + var sourceName = Guid.NewGuid().ToString("N"); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(sourceName)