.NET: Add AgentWorkflowBuilder group chat (#861)

* Add AgentWorkflowBuilder group chat

And fix a variety of issues along the way:
- Use DateTime{Offset}.UtcNow rather than Now
- AIAgentHostExecutor shouldn't be publishing empty messages
- Sequential workflows should be flowing all history and not just the output from the previous agent as the input into the next agent
- Renamed some of the new agent workflow methods... still not super happy with the shape, though
- Simplified handoffs builder, e.g. using a hashset with a custom comparer instead of a dictionary
- Improved multi-service use by trying to change assistant->user role for messages created by other agents
- Changed MessageMerger to rely on M.E.AI's coalescing more and to avoid empty contents / text
- Ensured that messages from ChatClientAgent include MessageId and CreatedAt timestamps
- Avoided including instructions for agents in a handoff workflow that don't have any handoffs
- Removed the unnecessary end function in handoffs
- Improved naming of executors to include agent name for debuggability
- Use "N" formatting with Guid.ToString everywhere, to avoid the unnecessary extra dash character which is also not valid in various places (like function tool names)
- Replace `params T[]` with `params IEnumerable<T>` to make public APIs more flexible in what they consume

* Address feedback

- Fix unintentional provider change in sample
This commit is contained in:
Stephen Toub
2025-09-24 12:38:34 -04:00
committed by GitHub
Unverified
parent 7c70c33157
commit 03ef7f054f
51 changed files with 888 additions and 619 deletions
+2 -2
View File
@@ -83,10 +83,10 @@
<!-- MCP -->
<PackageVersion Include="ModelContextProtocol" Version="0.3.0-preview.4" />
<!-- Inference SDKs -->
<PackageVersion Include="Anthropic.SDK" Version="5.5.1" />
<PackageVersion Include="Anthropic.SDK" Version="5.5.2" />
<PackageVersion Include="AWSSDK.Extensions.Bedrock.MEAI" Version="4.0.3.1" />
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.9.2" />
<PackageVersion Include="OllamaSharp" Version="5.4.4" />
<PackageVersion Include="OllamaSharp" Version="5.4.6" />
<!-- Identity -->
<PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.77.0" />
<!-- Workflows -->
@@ -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);
@@ -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<ChatMessage> Messages { get; set; } = new();
}
@@ -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)
@@ -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
/// </summary>
internal sealed class CustomAgentThread : InMemoryAgentThread
{
internal CustomAgentThread()
: base() { }
internal CustomAgentThread() { }
internal CustomAgentThread(JsonElement serializedThreadState, JsonSerializerOptions? jsonSerializerOptions = null)
: base(serializedThreadState, jsonSerializerOptions) { }
@@ -98,7 +98,7 @@ namespace SampleApp
public override async Task AddMessagesAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken)
{
this.ThreadDbKey ??= Guid.NewGuid().ToString();
this.ThreadDbKey ??= Guid.NewGuid().ToString("N");
var collection = this._vectorStore.GetCollection<string, ChatHistoryItem>("ChatHistory");
await collection.EnsureCollectionExistsAsync(cancellationToken);
@@ -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()
@@ -166,7 +166,7 @@ internal sealed class SpamDetectionExecutor : ReflectingExecutor<SpamDetectionEx
// Generate a random email ID and store the email content to the shared state
var newEmail = new Email
{
EmailId = Guid.NewGuid().ToString(),
EmailId = Guid.NewGuid().ToString("N"),
EmailContent = message.Text
};
await context.QueueStateUpdateAsync(newEmail.EmailId, newEmail, scopeName: EmailStateConstants.EmailStateScope);
@@ -190,7 +190,7 @@ internal sealed class SpamDetectionExecutor : ReflectingExecutor<SpamDetectionEx
// Generate a random email ID and store the email content
var newEmail = new Email
{
EmailId = Guid.NewGuid().ToString(),
EmailId = Guid.NewGuid().ToString("N"),
EmailContent = message.Text
};
await context.QueueStateUpdateAsync(newEmail.EmailId, newEmail, scopeName: EmailStateConstants.EmailStateScope);
@@ -247,7 +247,7 @@ internal sealed class EmailAnalysisExecutor : ReflectingExecutor<EmailAnalysisEx
// Generate a random email ID and store the email content
var newEmail = new Email
{
EmailId = Guid.NewGuid().ToString(),
EmailId = Guid.NewGuid().ToString("N"),
EmailContent = message.Text
};
await context.QueueStateUpdateAsync(newEmail.EmailId, newEmail, scopeName: EmailStateConstants.EmailStateScope);
@@ -62,7 +62,7 @@ internal sealed class FileReadExecutor() : ReflectingExecutor<FileReadExecutor>(
// 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;
@@ -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<ChatMessage> 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<List<ChatMessage>> RunWorkflowAsync(Workflow<List<ChatMessage>> workflow, List<ChatMessage> 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<FunctionCallContent>().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<ChatMessage>)completed.Data!;
}
}
@@ -10,8 +10,7 @@ namespace Microsoft.Agents.Orchestration;
/// </summary>
internal sealed class OrchestratingAgentThread : InMemoryAgentThread
{
internal OrchestratingAgentThread()
: base() { }
internal OrchestratingAgentThread() { }
internal OrchestratingAgentThread(JsonElement serializedThreadState, JsonSerializerOptions? jsonSerializerOptions = null)
: base(serializedThreadState, jsonSerializerOptions) { }
@@ -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,
};
}
}
@@ -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;
/// <summary>
/// Provides utility methods for constructing common patterns of agent workflows.
/// Provides utility methods for constructing common patterns of workflows composed of agents.
/// </summary>
public static class AgentWorkflowBuilder
public static partial class AgentWorkflowBuilder
{
/// <summary>
/// Builds a <see cref="Workflow{T}"/> 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<List<ChatMessage>>();
}
/// <summary>
/// Provides an executor that batches received chat messages that it then publishes as the final result
/// when receiving a <see cref="TurnToken"/>.
/// </summary>
private sealed class SequentialEndExecutor : Executor
{
private readonly List<ChatMessage> _pendingMessages = [];
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder
.AddHandler<ChatMessage>((message, context) => this._pendingMessages.Add(message))
.AddHandler<List<ChatMessage>>((messages, _) => this._pendingMessages.AddRange(messages))
.AddHandler<TurnToken>(async (token, context) =>
{
var messages = new List<ChatMessage>(this._pendingMessages);
this._pendingMessages.Clear();
await context.AddEventAsync(new WorkflowCompletedEvent(messages)).ConfigureAwait(false);
});
}
/// <summary>
/// Builds a <see cref="Workflow{T}"/> 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 <see cref="AIAgent"/> must be capable of understanding those <see cref="AgentRunOptions"/> provided. If the agent
/// ignores the tools or is otherwise unable to advertize them to the underlying provider, handoffs will not occur.
/// </remarks>
public static HandoffsWorkflowBuilder StartHandoffWith(AIAgent initialAgent)
public static HandoffsWorkflowBuilder CreateHandoffBuilderWith(AIAgent initialAgent)
{
Throw.IfNull(initialAgent);
return new(initialAgent);
}
/// <summary>Executor that forwards all relevant messages.</summary>
/// <summary>Creates a new <see cref="GroupChatWorkflowBuilder"/> with <paramref name="managerFactory"/>.</summary>
/// <param name="managerFactory">
/// Function that will create the <see cref="GroupChatManager"/> for the workflow instance. The manager will be
/// provided with the set of agents that will participate in the group chat.
/// </param>
/// <returns>The builder for creating a workflow based on handoffs.</returns>
/// <remarks>
/// Handoffs between agents are achieved by the current agent invoking an <see cref="AITool"/> provided to an agent
/// via <see cref="ChatClientAgentOptions"/>'s <see cref="ChatClientAgentOptions.ChatOptions"/>.<see cref="ChatOptions.Tools"/>.
/// The <see cref="AIAgent"/> must be capable of understanding those <see cref="AgentRunOptions"/> provided. If the agent
/// ignores the tools or is otherwise unable to advertize them to the underlying provider, handoffs will not occur.
/// </remarks>
public static GroupChatWorkflowBuilder CreateGroupChatBuilderWith(Func<IReadOnlyList<AIAgent>, GroupChatManager> managerFactory)
{
Throw.IfNull(managerFactory);
return new GroupChatWorkflowBuilder(managerFactory);
}
/// <summary>
/// Executor that runs the agent and forwards all messages, input and output, to the next executor.
/// </summary>
private sealed class AgentRunStreamingExecutor(AIAgent agent, bool includeInputInOutput) : Executor(GetDescriptiveIdFromAgent(agent))
{
private readonly List<ChatMessage> _pendingMessages = [];
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder
.AddHandler<string>((message, context) => this._pendingMessages.Add(new(ChatRole.User, message)))
.AddHandler<ChatMessage>((message, context) => this._pendingMessages.Add(message))
.AddHandler<IEnumerable<ChatMessage>>((messages, _) => this._pendingMessages.AddRange(messages))
.AddHandler<ChatMessage[]>((messages, _) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed
.AddHandler<List<ChatMessage>>((messages, _) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed
.AddHandler<TurnToken>(async (token, context) =>
{
List<ChatMessage> messages = [.. this._pendingMessages];
this._pendingMessages.Clear();
List<ChatMessage>? roleChanged = ChangeAssistantToUserForOtherParticipants(agent.DisplayName, messages);
List<AgentRunResponseUpdate> 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);
});
}
/// <summary>
/// Provides an executor that batches received chat messages that it then publishes as the final result
/// when receiving a <see cref="TurnToken"/>.
/// </summary>
private sealed class ConvertMessageListToCompletedEventExecutor : Executor
{
private readonly List<ChatMessage> _pendingMessages = [];
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder
.AddHandler<ChatMessage>((message, context) => this._pendingMessages.Add(message))
.AddHandler<List<ChatMessage>>((messages, _) => this._pendingMessages.AddRange(messages))
.AddHandler<TurnToken>(async (token, context) =>
{
var messages = new List<ChatMessage>(this._pendingMessages);
this._pendingMessages.Clear();
await context.AddEventAsync(new WorkflowCompletedEvent(messages)).ConfigureAwait(false);
});
}
/// <summary>Executor that forwards all messages.</summary>
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 <see cref="TurnToken"/>.
/// </summary>
private sealed class ChatMessageBatchingExecutor : Executor
private sealed class BatchChatMessagesToListExecutor : Executor
{
private readonly List<ChatMessage> _pendingMessages = [];
@@ -195,26 +257,36 @@ public static class AgentWorkflowBuilder
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<List<ChatMessage>>(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<List<ChatMessage>>(this._expectedInputs);
await context.AddEventAsync(new WorkflowCompletedEvent(this._aggregator(results))).ConfigureAwait(false);
}
});
}
/// <summary>
/// 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.
/// </summary>
public sealed class HandoffsWorkflowBuilder
{
private const string FunctionPrefix = "handoff_to_";
private readonly AIAgent _initialAgent;
private readonly Dictionary<AIAgent, HashSet<HandoffTarget>> _targets = [];
private readonly Dictionary<string, AIAgent> _allAgents = [];
private readonly HashSet<AIAgent> _allAgents = new(AIAgentIDEqualityComparer.Instance);
/// <summary>
/// Initializes a new instance of the <see cref="HandoffsWorkflowBuilder"/> 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);
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// By default, simple instructions are included. This may be set to <see langword="null"/> to avoid including
@@ -235,9 +307,10 @@ public static class AgentWorkflowBuilder
/// </remarks>
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}<agent_id>`. 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}<agent_id>`; 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.
""";
/// <summary>
@@ -246,8 +319,8 @@ public static class AgentWorkflowBuilder
/// <param name="from">The source agent.</param>
/// <param name="to">The target agents to add as handoff targets for the source agent.</param>
/// <returns>The updated <see cref="HandoffsWorkflowBuilder"/> instance.</returns>
/// <remarks>The handoff reason for each target is derived from its description or name.</remarks>
public HandoffsWorkflowBuilder WithHandoff(AIAgent from, IEnumerable<AIAgent> to)
/// <remarks>The handoff reason for each target in <paramref name="to"/> is derived from that agent's description or name.</remarks>
public HandoffsWorkflowBuilder WithHandoffs(AIAgent from, IEnumerable<AIAgent> to)
{
Throw.IfNull(from);
Throw.IfNull(to);
@@ -265,32 +338,51 @@ public static class AgentWorkflowBuilder
return this;
}
/// <summary>
/// Adds handoff relationships from one or more sources agent to a target agent.
/// </summary>
/// <param name="from">The source agents.</param>
/// <param name="to">The target agent to add as a handoff target for each source agent.</param>
/// <param name="handoffReason">
/// The reason the <paramref name="from"/> should hand off to the <paramref name="to"/>.
/// If <see langword="null"/>, the reason is derived from <paramref name="to"/>'s description or name.
/// </param>
/// <returns>The updated <see cref="HandoffsWorkflowBuilder"/> instance.</returns>
public HandoffsWorkflowBuilder WithHandoffs(IEnumerable<AIAgent> 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;
}
/// <summary>
/// Adds a handoff relationship from a source agent to a target agent with a custom handoff reason.
/// </summary>
/// <param name="from">The source agent.</param>
/// <param name="to">The target agent.</param>
/// <param name="handoffReason">The reason the <paramref name="from"/> should hand off to the <paramref name="to"/>.</param>
/// <param name="handoffReason">
/// The reason the <paramref name="from"/> should hand off to the <paramref name="to"/>.
/// If <see langword="null"/>, the reason is derived from <paramref name="to"/>'s description or name.
/// </param>
/// <returns>The updated <see cref="HandoffsWorkflowBuilder"/> instance.</returns>
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
/// <returns>The workflow built based on the handoffs in the builder.</returns>
public Workflow<List<ChatMessage>> Build()
{
StartHandoffs start = new();
EndExecutor end = new();
StartHandoffsExecutor start = new();
EndHandoffsExecutor end = new();
WorkflowBuilder builder = new(start);
// Create an AgentExecutor for each again.
Dictionary<string, AgentExecutor> executors = this._allAgents.ToDictionary(a => a.Key, a => new AgentExecutor(a.Value, this.HandoffInstructions));
Dictionary<string, HandoffAgentExecutor> 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<HandoffTarget>? targets) ? targets : []);
executors[agent.Id].Initialize(builder, end, executors,
this._targets.TryGetValue(agent, out HashSet<HandoffTarget>? targets) ? targets : []);
}
// Build the workflow.
@@ -353,7 +445,7 @@ public static class AgentWorkflowBuilder
}
/// <summary>Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token.</summary>
private sealed class StartHandoffs : Executor
private sealed class StartHandoffsExecutor : Executor
{
private readonly List<ChatMessage> _pendingMessages = [];
@@ -373,7 +465,7 @@ public static class AgentWorkflowBuilder
}
/// <summary>Executor used at the end of a handoff workflow to raise a final completed event.</summary>
private sealed class EndExecutor : Executor
private sealed class EndHandoffsExecutor : Executor
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<HandoffState>((handoff, context) =>
@@ -381,45 +473,47 @@ public static class AgentWorkflowBuilder
}
/// <summary>Executor used to represent an agent in a handoffs workflow, responding to <see cref="HandoffState"/> events.</summary>
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<string> _handoffFunctionNames = [];
private readonly ChatClientAgentRunOptions _agentOptions = new()
{
ChatOptions = new()
{
Instructions = instructions,
Tools = [s_endFunction],
}
};
private ChatClientAgentRunOptions? _agentOptions;
public void Initialize(
WorkflowBuilder builder,
Executor end,
Dictionary<string, AgentExecutor> executors,
IEnumerable<HandoffTarget> handoffs) =>
Dictionary<string, HandoffAgentExecutor> executors,
HashSet<HandoffTarget> 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<HandoffState>(state => state?.InvokedHandoff == handoffFunc.Name, executors[handoff.Target.Id]);
this._agentOptions.ChatOptions.Tools.Add(handoffFunc);
sb.AddCase<HandoffState>(state => state?.InvokedHandoff == handoffFunc.Name, executors[handoff.Target.Id]);
}
}
sb.WithDefault(end);
@@ -432,43 +526,34 @@ public static class AgentWorkflowBuilder
List<AgentRunResponseUpdate> updates = [];
List<ChatMessage> allMessages = handoffState.Messages;
while (requestedHandoff is null)
List<ChatMessage>? 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<ChatMessage> Messages);
}
private static string CreateId() =>
/// <summary>
/// A manager that manages the flow of a group chat.
/// </summary>
public abstract class GroupChatManager
{
private int _maximumIterationCount = 40;
/// <summary>
/// Initializes a new instance of the <see cref="GroupChatManager"/> class.
/// </summary>
protected GroupChatManager() { }
/// <summary>
/// Gets the number of iterations in the group chat so far.
/// </summary>
public int IterationCount { get; internal set; }
/// <summary>
/// Gets or sets the maximum number of iterations allowed.
/// </summary>
/// <remarks>
/// Each iteration involves a single interaction with a participating agent.
/// The default is 40.
/// </remarks>
public int MaximumIterationCount
{
get => this._maximumIterationCount;
set => this._maximumIterationCount = Throw.IfLessThan(value, 1);
}
/// <summary>
/// Selects the next agent to participate in the group chat based on the provided chat history and team.
/// </summary>
/// <param name="history">The chat history to consider.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>The next <see cref="AIAgent"/> to speak. This agent must be part of the chat.</returns>
protected internal abstract ValueTask<AIAgent> SelectNextAgentAsync(
IReadOnlyList<ChatMessage> history,
CancellationToken cancellationToken = default);
/// <summary>
/// Filters the chat history before it's passed to the next agent.
/// </summary>
/// <param name="history">The chat history to filter.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>The filtered chat history.</returns>
protected internal virtual ValueTask<IEnumerable<ChatMessage>> UpdateHistoryAsync(
IReadOnlyList<ChatMessage> history,
CancellationToken cancellationToken = default) =>
new(history);
/// <summary>
/// Determines whether the group chat should be terminated based on the provided chat history and iteration count.
/// </summary>
/// <param name="history">The chat history to consider.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A <see cref="bool"/> indicating whether the chat should be terminated.</returns>
protected internal virtual ValueTask<bool> ShouldTerminateAsync(
IReadOnlyList<ChatMessage> history,
CancellationToken cancellationToken = default) =>
new(this.MaximumIterationCount is int max && this.IterationCount >= max);
/// <summary>
/// Resets the state of the manager for a new group chat session.
/// </summary>
protected internal virtual void Reset()
{
this.IterationCount = 0;
}
}
/// <summary>
/// Provides a <see cref="GroupChatManager"/> that selects agents in a round-robin fashion.
/// </summary>
public class RoundRobinGroupChatManager : GroupChatManager
{
private readonly IReadOnlyList<AIAgent> _agents;
private readonly Func<RoundRobinGroupChatManager, IEnumerable<ChatMessage>, CancellationToken, ValueTask<bool>>? _shouldTerminateFunc;
private int _nextIndex;
/// <summary>
/// Initializes a new instance of the <see cref="RoundRobinGroupChatManager"/> class.
/// </summary>
/// <param name="agents">The agents to be managed as part of this workflow.</param>
/// <param name="shouldTerminateFunc">
/// 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.
/// </param>
public RoundRobinGroupChatManager(
IReadOnlyList<AIAgent> agents,
Func<RoundRobinGroupChatManager, IEnumerable<ChatMessage>, CancellationToken, ValueTask<bool>>? shouldTerminateFunc = null)
{
Throw.IfNullOrEmpty(agents);
foreach (var agent in agents)
{
Throw.IfNull(agent, nameof(agents));
}
this._agents = agents;
this._shouldTerminateFunc = shouldTerminateFunc;
}
/// <inheritdoc />
protected internal override ValueTask<AIAgent> SelectNextAgentAsync(
IReadOnlyList<ChatMessage> history, CancellationToken cancellationToken = default)
{
AIAgent nextAgent = this._agents[this._nextIndex];
this._nextIndex = (this._nextIndex + 1) % this._agents.Count;
return new ValueTask<AIAgent>(nextAgent);
}
/// <inheritdoc />
protected internal override async ValueTask<bool> ShouldTerminateAsync(
IReadOnlyList<ChatMessage> 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);
}
/// <inheritdoc />
protected internal override void Reset()
{
base.Reset();
this._nextIndex = 0;
}
}
/// <summary>
/// Provides a builder for specifying group chat relationships between agents and building the resulting workflow.
/// </summary>
public sealed class GroupChatWorkflowBuilder
{
private readonly Func<IReadOnlyList<AIAgent>, GroupChatManager> _managerFactory;
private readonly HashSet<AIAgent> _participants = new(AIAgentIDEqualityComparer.Instance);
internal GroupChatWorkflowBuilder(Func<IReadOnlyList<AIAgent>, GroupChatManager> managerFactory) =>
this._managerFactory = managerFactory;
/// <summary>
/// Adds the specified <paramref name="agents"/> as participants to the group chat workflow.
/// </summary>
/// <param name="agents">The agents to add as participants.</param>
/// <returns>This instance of the <see cref="GroupChatWorkflowBuilder"/>.</returns>
public GroupChatWorkflowBuilder AddParticipants(params IEnumerable<AIAgent> 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;
}
/// <summary>
/// Builds a <see cref="Workflow{T}"/> composed of agents that operate via group chat, with the next
/// agent to process messages selected by the group chat manager.
/// </summary>
/// <returns>The workflow built based on the group chat in the builder.</returns>
public Workflow<List<ChatMessage>> Build()
{
AIAgent[] agents = this._participants.ToArray();
Dictionary<AIAgent, ExecutorIsh> 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<List<ChatMessage>>();
}
private sealed class GroupChatHost(AIAgent[] agents, Dictionary<AIAgent, ExecutorIsh> agentMap, Func<IReadOnlyList<AIAgent>, GroupChatManager> managerFactory) : Executor
{
private readonly AIAgent[] _agents = agents;
private readonly Dictionary<AIAgent, ExecutorIsh> _agentMap = agentMap;
private readonly Func<IReadOnlyList<AIAgent>, GroupChatManager> _managerFactory = managerFactory;
private readonly List<ChatMessage> _pendingMessages = [];
private GroupChatManager? _manager;
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => routeBuilder
.AddHandler<string>((message, context) => this._pendingMessages.Add(new(ChatRole.User, message)))
.AddHandler<ChatMessage>((message, context) => this._pendingMessages.Add(message))
.AddHandler<IEnumerable<ChatMessage>>((messages, _) => this._pendingMessages.AddRange(messages))
.AddHandler<ChatMessage[]>((messages, _) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed
.AddHandler<List<ChatMessage>>((messages, _) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed
.AddHandler<TurnToken>(async (token, context) =>
{
List<ChatMessage> 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);
});
}
}
/// <summary>
/// Iterates through <paramref name="messages"/> looking for <see cref="ChatRole.Assistant"/> messages and swapping
/// any that have a different <see cref="ChatMessage.AuthorName"/> from <paramref name="targetAgentName"/> to <see cref="ChatRole.User"/>.
/// </summary>
private static List<ChatMessage>? ChangeAssistantToUserForOtherParticipants(string targetAgentName, List<ChatMessage> messages)
{
List<ChatMessage>? 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;
}
/// <summary>
/// Undoes changes made by <see cref="ChangeAssistantToUserForOtherParticipants(string, List{ChatMessage})"/>
/// when passed the list of changes made by that method.
/// </summary>
private static void ResetUserToAssistantForChangedRoles(List<ChatMessage>? roleChanged)
{
if (roleChanged is not null)
{
foreach (var m in roleChanged)
{
m.Role = ChatRole.Assistant;
}
}
}
/// <summary>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.</summary>
private static string GetDescriptiveIdFromAgent(AIAgent agent)
{
string id = string.IsNullOrEmpty(agent.Name) ? agent.Id : $"{agent.Name}_{agent.Id}";
return InvalidNameCharsRegex().Replace(id, "_");
}
/// <summary>Regex that flags any character other than ASCII digits or letters or the underscore.</summary>
#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<AIAgent>
{
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;
}
}
@@ -57,9 +57,7 @@ internal sealed class MessageMerger
public List<ChatMessage> ComputeFlattened()
{
List<ChatMessage> result = this.UpdatesByMessageId.Keys.Select(AggregateUpdatesToMessage)
.ToList();
List<ChatMessage> 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<ChatMessage> AggregateUpdatesToMessage(string messageId)
{
List<AgentRunResponseUpdate> 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
};
+3 -3
View File
@@ -118,7 +118,7 @@ public class Run
/// <param name="cancellation">A <see cref="CancellationToken"/> that can be used to cancel the workflow execution.</param>
/// <param name="responses">An array of <see cref="ExternalResponse"/> objects to send to the workflow.</param>
/// <returns><c>true</c> if the workflow had any output events, <c>false</c> otherwise.</returns>
public async ValueTask<bool> ResumeAsync(CancellationToken cancellation = default, params ExternalResponse[] responses)
public async ValueTask<bool> ResumeAsync(CancellationToken cancellation = default, params IEnumerable<ExternalResponse> responses)
{
foreach (ExternalResponse response in responses)
{
@@ -135,9 +135,9 @@ public class Run
/// <param name="messages">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 <see cref="ExternalResponse"/>.</param>
/// <returns><c>true</c> if the workflow had any output events, <c>false</c> otherwise.</returns>
public async ValueTask<bool> ResumeAsync<T>(CancellationToken cancellation = default, params T[] messages)
public async ValueTask<bool> ResumeAsync<T>(CancellationToken cancellation = default, params IEnumerable<T> messages)
{
if (messages is ExternalResponse[] responses)
if (messages is IEnumerable<ExternalResponse> responses)
{
return await this.ResumeAsync(cancellation, responses).ConfigureAwait(false);
}
@@ -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 = [];
@@ -30,7 +30,7 @@ public sealed class SwitchBuilder
/// <param name="executors">One or more executors to associate with the predicate. Each executor will be invoked if the predicate matches.
/// Cannot be null.</param>
/// <returns>The current <see cref="SwitchBuilder"/> instance, allowing for method chaining.</returns>
public SwitchBuilder AddCase<T>(Func<T?, bool> predicate, params ExecutorIsh[] executors)
public SwitchBuilder AddCase<T>(Func<T?, bool> predicate, params IEnumerable<ExecutorIsh> executors)
{
Throw.IfNull(predicate);
Throw.IfNull(executors);
@@ -60,7 +60,7 @@ public sealed class SwitchBuilder
/// </summary>
/// <param name="executors"></param>
/// <returns></returns>
public SwitchBuilder WithDefault(params ExecutorIsh[] executors)
public SwitchBuilder WithDefault(params IEnumerable<ExecutorIsh> executors)
{
Throw.IfNull(executors);
@@ -211,7 +211,7 @@ public class WorkflowBuilder
/// <param name="source">The source executor from which the fan-out edge originates. Cannot be null.</param>
/// <param name="targets">One or more target executors that will receive the fan-out edge. Cannot be null or empty.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, params ExecutorIsh[] targets)
public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, params IEnumerable<ExecutorIsh> targets)
=> this.AddFanOutEdge<object>(source, null, targets);
internal static Func<object?, int, IEnumerable<int>>? CreateEdgeAssignerFunc<T>(Func<T?, int, IEnumerable<int>>? partitioner)
@@ -243,16 +243,24 @@ public class WorkflowBuilder
/// If null, messages will route to all targets.</param>
/// <param name="targets">One or more target executors that will receive the fan-out edge. Cannot be null or empty.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
public WorkflowBuilder AddFanOutEdge<T>(ExecutorIsh source, Func<T?, int, IEnumerable<int>>? partitioner = null, params ExecutorIsh[] targets)
public WorkflowBuilder AddFanOutEdge<T>(ExecutorIsh source, Func<T?, int, IEnumerable<int>>? partitioner = null, params IEnumerable<ExecutorIsh> targets)
{
Throw.IfNull(source);
Throw.IfNullOrEmpty(targets);
Throw.IfNull(targets);
List<string> 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
/// <param name="target">The target executor that receives input from the specified source executors. Cannot be null.</param>
/// <param name="sources">One or more source executors that provide input to the target. Cannot be null or empty.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
public WorkflowBuilder AddFanInEdge(ExecutorIsh target, params ExecutorIsh[] sources)
public WorkflowBuilder AddFanInEdge(ExecutorIsh target, params IEnumerable<ExecutorIsh> sources)
{
Throw.IfNull(target);
Throw.IfNullOrEmpty(sources);
Throw.IfNull(sources);
List<string> 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)
{
@@ -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
/// <param name="source">The source executor from which messages will be forwarded.</param>
/// <param name="executors">The target executors to which messages will be forwarded.</param>
/// <returns>The updated <see cref="WorkflowBuilder"/> instance.</returns>
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorIsh source, params ExecutorIsh[] executors)
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorIsh source, params IEnumerable<ExecutorIsh> executors)
{
Throw.IfNullOrEmpty(executors);
Throw.IfNull(executors);
Func<object?, bool> predicate = WorkflowBuilder.CreateConditionFunc<TMessage>((Func<object?, bool>)IsAllowedType)!;
if (executors.Length == 1)
#if NET
if (executors.TryGetNonEnumeratedCount(out int count) && count == 1)
#else
if (executors is ICollection<ExecutorIsh> { 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
/// <param name="source">The source executor from which messages will be forwarded.</param>
/// <param name="executors">The target executors to which messages, except those of type <typeparamref name="TMessage"/>, will be forwarded.</param>
/// <returns>The updated <see cref="WorkflowBuilder"/> instance with the added edges.</returns>
public static WorkflowBuilder ForwardExcept<TMessage>(this WorkflowBuilder builder, ExecutorIsh source, params ExecutorIsh[] executors)
public static WorkflowBuilder ForwardExcept<TMessage>(this WorkflowBuilder builder, ExecutorIsh source, params IEnumerable<ExecutorIsh> executors)
{
Throw.IfNullOrEmpty(executors);
Throw.IfNull(executors);
Func<object?, bool> predicate = WorkflowBuilder.CreateConditionFunc<TMessage>((Func<object?, bool>)IsAllowedType)!;
if (executors.Length == 1)
#if NET
if (executors.TryGetNonEnumeratedCount(out int count) && count == 1)
#else
if (executors is ICollection<ExecutorIsh> { 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
/// <param name="executors">An ordered array of executors to be added to the chain after the source.</param>
/// <returns>The original workflow builder instance with the specified executor chain added.</returns>
/// <exception cref="ArgumentException">Thrown if there is a cycle in the chain.</exception>
public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh source, params ExecutorIsh[] executors)
public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh source, params IEnumerable<ExecutorIsh> executors)
{
Throw.IfNull(builder);
Throw.IfNull(source);
HashSet<string> 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;
@@ -44,7 +44,7 @@ internal sealed class WorkflowMessageStore : ChatMessageStore
public IList<ChatMessage> Messages { get; set; } = [];
}
internal void AddMessages(params ChatMessage[] messages) => this._chatMessages.AddRange(messages);
internal void AddMessages(params IEnumerable<ChatMessage> messages) => this._chatMessages.AddRange(messages);
public override Task AddMessagesAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken)
{
@@ -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"),
};
@@ -53,7 +53,7 @@ internal sealed class A2AAgent : AIAgent
}
/// <inheritdoc/>
public override sealed AgentThread GetNewThread()
public sealed override AgentThread GetNewThread()
=> new A2AAgentThread();
/// <summary>
@@ -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,
};
@@ -21,7 +21,7 @@ public abstract class AIAgent
/// <value>
/// 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.
/// </value>
public virtual string Id { get; } = Guid.NewGuid().ToString();
public virtual string Id { get; } = Guid.NewGuid().ToString("N");
/// <summary>
/// Gets the name of the agent (optional).
@@ -19,7 +19,7 @@ public abstract class InMemoryAgentThread : AgentThread
/// <param name="messageStore">An optional <see cref="InMemoryChatMessageStore"/> to use for storing chat messages. If null, a new instance will be created.</param>
protected InMemoryAgentThread(InMemoryChatMessageStore? messageStore = null)
{
this.MessageStore = messageStore ?? new InMemoryChatMessageStore();
this.MessageStore = messageStore ?? [];
}
/// <summary>
@@ -28,11 +28,7 @@ public abstract class InMemoryAgentThread : AgentThread
/// <param name="messages">The messages to initialize the thread with.</param>
protected InMemoryAgentThread(IEnumerable<ChatMessage> messages)
{
this.MessageStore = new InMemoryChatMessageStore();
foreach (var message in messages)
{
this.MessageStore.Add(message);
}
this.MessageStore = [.. messages];
}
/// <summary>
@@ -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 =
@@ -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)
@@ -41,7 +41,7 @@ public class CopilotStudioAgent : AIAgent
}
/// <inheritdoc/>
public override sealed AgentThread GetNewThread()
public sealed override AgentThread GetNewThread()
=> new CopilotStudioAgentThread();
/// <summary>
@@ -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
@@ -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 = []
};
@@ -30,7 +30,7 @@ internal sealed class A2AAgentWrapper
public async Task<Message> 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!);
@@ -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,
@@ -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 };
}
@@ -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
}
/// <summary>
@@ -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<ActorStateWriteOperation>
@@ -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");
@@ -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<ActorStateReadOperation>
@@ -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"];
@@ -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<ActorStateWriteOperation>();
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(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<ActorStateReadOperation>
{
@@ -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[]
@@ -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");
@@ -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<ArgumentNullException>("initialAgent", () => AgentWorkflowBuilder.StartHandoffWith(null!));
Assert.Throws<ArgumentNullException>("initialAgent", () => AgentWorkflowBuilder.CreateHandoffBuilderWith(null!));
var agent = new DoubleEchoAgent("agent");
var handoffs = AgentWorkflowBuilder.StartHandoffWith(agent);
var handoffs = AgentWorkflowBuilder.CreateHandoffBuilderWith(agent);
Assert.NotNull(handoffs);
Assert.Throws<ArgumentNullException>("from", () => handoffs.WithHandoff(null!, new DoubleEchoAgent("a2")));
Assert.Throws<ArgumentNullException>("to", () => handoffs.WithHandoff(new DoubleEchoAgent("a2"), (AIAgent)null!));
Assert.Throws<ArgumentNullException>("to", () => handoffs.WithHandoff(new DoubleEchoAgent("a2"), null!));
Assert.Throws<ArgumentNullException>("to", () => handoffs.WithHandoff(new DoubleEchoAgent("a2"), [null!]));
Assert.Throws<ArgumentNullException>("from", () => handoffs.WithHandoffs(null!, new DoubleEchoAgent("a2")));
Assert.Throws<ArgumentNullException>("from", () => handoffs.WithHandoffs([null!], new DoubleEchoAgent("a2")));
Assert.Throws<ArgumentNullException>("to", () => handoffs.WithHandoffs(new DoubleEchoAgent("a2"), null!));
Assert.Throws<ArgumentNullException>("to", () => handoffs.WithHandoffs(new DoubleEchoAgent("a2"), [null!]));
var noDescriptionAgent = new ChatClientAgent(new MockChatClient(delegate { return new(); }));
Assert.Throws<ArgumentException>("to", () => handoffs.WithHandoff(agent, noDescriptionAgent));
}
[Fact]
public async Task BuildSequential_AgentsRunInOrderAsync()
public void BuildGroupChat_InvalidArguments_Throws()
{
Assert.Throws<ArgumentNullException>("managerFactory", () => AgentWorkflowBuilder.CreateGroupChatBuilderWith(null!));
var groupChat = AgentWorkflowBuilder.CreateGroupChatBuilderWith(_ => new AgentWorkflowBuilder.RoundRobinGroupChatManager([new DoubleEchoAgent("a1")]));
Assert.NotNull(groupChat);
Assert.Throws<ArgumentNullException>("agents", () => groupChat.AddParticipants(null!));
Assert.Throws<ArgumentNullException>("agents", () => groupChat.AddParticipants([null!]));
Assert.Throws<ArgumentNullException>("agents", () => groupChat.AddParticipants(new DoubleEchoAgent("a1"), null!));
Assert.Throws<ArgumentNullException>("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<ArgumentOutOfRangeException>("value", () => manager.MaximumIterationCount = 0);
Assert.Throws<ArgumentOutOfRangeException>("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<ChatMessage>? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
const string Expected = "agent1abcabcagent2agent1abcabcagent1abcabcagent3agent2agent1abcabcagent1abcabcagent2agent1abcabcagent1abcabc";
Assert.Equal(Expected, updateText);
const string UserInput = "abc";
(string updateText, List<ChatMessage>? 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<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> 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<ChatMessage>? 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<TextContent>(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<ChatMessage>? 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<ChatMessage>? Result)> RunWorkflowAsync(
Workflow<List<ChatMessage>> workflow, List<ChatMessage> input)
{
@@ -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<AIContent> 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,
};
}
@@ -17,16 +17,11 @@ namespace Microsoft.Agents.Workflows.Sample;
internal static class Step6EntryPoint
{
public static Workflow<List<ChatMessage>> CreateWorkflow(int maxTurns)
{
GroupChatBuilder builder =
GroupChatBuilder.Create<RoundRobinGroupChatManager, RoundRobinGroupChatManagerOptions>
(options => options.MaxTurns = maxTurns)
.AddParticipant(new HelloAgent(), shouldEmitEvents: true)
.AddParticipant(new EchoAgent(), shouldEmitEvents: true);
return builder.ReduceToWorkflow();
}
public static Workflow<List<ChatMessage>> 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<RoundRobinGroupChatManagerOptions>
{
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<ChatMessage> _messages = [];
private int _bookmark;
public void AddMessage(ChatMessage message) =>
this._messages.Add(message);
public void AddMessages(IEnumerable<ChatMessage> messages) =>
this._messages.AddRange(messages);
public void UpdateBookmark() =>
this._bookmark = this._messages.Count;
public IReadOnlyList<ChatMessage> FullHistory => this._messages.AsReadOnly();
public IEnumerable<ChatMessage> 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<TOptions> : GroupChatManager where TOptions : GroupChatManagerOptions, new()
{
protected internal virtual void Configure(TOptions options) { }
}
internal sealed class GroupChatBuilder
{
private readonly List<ExecutorIsh> _participants = [];
private readonly List<bool> _shouldEmitEvents = [];
private readonly Func<string[], GroupChatManager> _managerFactory;
private GroupChatBuilder(Func<string[], GroupChatManager> managerFactory)
{
this._managerFactory = managerFactory;
}
public static GroupChatBuilder Create<TManager>() where TManager : GroupChatManager, new() =>
new(participantIds => new TManager() { ParticipantIds = participantIds });
public static GroupChatBuilder Create<TManager, TOptions>(Action<TOptions> configure)
where TManager : GroupChatManager<TOptions>, 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<List<ChatMessage>> 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<List<ChatMessage>>();
//bool IsMessageType(object? message) => message is ChatMessage || message is IEnumerable<ChatMessage>;
}
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<List<ChatMessage>>(this.HandleChatMessagesAsync)
.AddHandler<ChatMessage>(this.HandleChatMessageAsync)
.AddHandler<TurnToken>(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<ChatMessage> 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;
/// <summary>
/// Atomically switches to "in conversation" state if not already in that state.
/// </summary>
/// <returns><see langword="true"/> if the state was changed, <see langword="false"/> otherwise.</returns>
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);
}
}
}
@@ -43,7 +43,7 @@ public class SpecializedExecutorSmokeTests
{
MessageId = Guid.NewGuid().ToString("N"),
RawRepresentation = text,
CreatedAt = DateTime.Now,
CreatedAt = DateTime.UtcNow,
};
}
@@ -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<ChatMessage> newMessages, CancellationToken cancellationToken = default)
=> base.MessagesReceivedAsync(newMessages, cancellationToken);
public override Task<JsonElement> SerializeAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> base.SerializeAsync(jsonSerializerOptions, cancellationToken);
}
private sealed class TestAgentThread : AgentThread;
}
@@ -78,7 +78,7 @@ public class ChatMessageStoreTests
private sealed class TestChatMessageStore : ChatMessageStore
{
public override Task<IEnumerable<ChatMessage>> GetMessagesAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<IEnumerable<ChatMessage>>(Array.Empty<ChatMessage>());
=> Task.FromResult<IEnumerable<ChatMessage>>([]);
public override Task AddMessagesAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
@@ -300,9 +300,7 @@ public class DelegatingAIAgentTests
public new AIAgent InnerAgent => base.InnerAgent;
}
private sealed class TestAgentThread : AgentThread
{
}
private sealed class TestAgentThread : AgentThread;
#endregion
}
@@ -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<ChatMessage> messages) : base(messages) { }
public TestInMemoryAgentThread(JsonElement serializedThreadState) : base(serializedThreadState) { }
public InMemoryChatMessageStore GetMessageStore() => this.MessageStore;
public override Task<JsonElement> SerializeAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => base.SerializeAsync(jsonSerializerOptions, cancellationToken);
}
}
@@ -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<JsonElement> SerializeAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => base.SerializeAsync(jsonSerializerOptions, cancellationToken);
}
}
@@ -104,8 +104,10 @@ public class AgentActorTests
var threadJson = JsonSerializer.SerializeToElement(new { conversationId = "expected-thread-id" });
var mockThread = new Mock<AgentThread>();
var testAgent = new TestAgent();
testAgent.ThreadForCreate = mockThread.Object;
TestAgent testAgent = new()
{
ThreadForCreate = mockThread.Object
};
var mockContext = new Mock<IActorRuntimeContext>();
var actorId = new ActorId("TestAgent", "test-instance");
@@ -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<AIContextProvider> 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<JsonSerializerOptions?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(providerStateElement);
var thread = new ChatClientAgentThread();
thread.AIContextProvider = mockProvider.Object;
var thread = new ChatClientAgentThread
{
AIContextProvider = mockProvider.Object
};
// Act
var json = await thread.SerializeAsync();
@@ -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<Activity>();
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<Activity>();
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<Activity>();
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<Activity>();
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<Activity>();
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<Activity>();
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<Activity>();
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<Activity>();
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<Activity>();
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<ILogger>();
// 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<Activity>();
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<Activity>();
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<IChatClient>();
// 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<Activity>();
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<Activity>();
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<Activity>();
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<Activity>();
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<Activity>();
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<Activity>();
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<Activity>();
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<Activity>();
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<Activity>();
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<Activity>();
var exportedMetrics = new List<Metric>();
@@ -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<Activity>();
var exportedMetrics = new List<Metric>();
@@ -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<Activity>();
var exportedMetrics = new List<Metric>();
@@ -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<Metric>();
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<Activity>();
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<Metric>();
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<Activity>();
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<Activity>();
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<Activity>();
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<Activity>();
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<Activity>();
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<Activity>();
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<Activity>();
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<Activity>();
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<Activity>();
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<Activity>();
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<ILogger>();
var mockAgent = new Mock<AIAgent>();
@@ -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<ILogger>();
var mockAgent = new Mock<AIAgent>();
@@ -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<ILogger>();
var mockAgent = new Mock<AIAgent>();
@@ -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<Activity>();
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
.AddSource(sourceName)