diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index f799bc6df6..0698b8abca 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -109,6 +109,7 @@ + diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/04_AgentWorkflowPatterns.csproj b/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/04_AgentWorkflowPatterns.csproj new file mode 100644 index 0000000000..267e25180e --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/04_AgentWorkflowPatterns.csproj @@ -0,0 +1,23 @@ + + + + Exe + net9.0 + + enable + disable + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/Program.cs new file mode 100644 index 0000000000..c8604244b5 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/Program.cs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.Workflows; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; + +namespace WorkflowAgentsInWorkflowsSample; + +/// +/// This sample introduces the use of AI agents as executors within a workflow, +/// using to compose the agents into one of +/// several common patterns. +/// +/// +/// Pre-requisites: +/// - An Azure OpenAI chat completion deployment must be configured. +/// +public static class Program +{ + private static async Task Main() + { + // Set up the Azure OpenAI client. + var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); + 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'): "); + switch (Console.ReadLine()) + { + case "sequential": + await RunWorkflowAsync( + AgentWorkflowBuilder.BuildSequential(from lang in (string[])["French", "Spanish", "English"] select GetTranslationAgent(lang, client)), + [new(ChatRole.User, "Hello, world!")]); + break; + + case "concurrent": + await RunWorkflowAsync( + AgentWorkflowBuilder.BuildConcurrent(from lang in (string[])["French", "Spanish", "English"] select GetTranslationAgent(lang, client)), + [new(ChatRole.User, "Hello, world!")]); + break; + + case "handoffs": + ChatClientAgent historyTutor = new(client, + "You provide assistance with historical queries. Explain important events and context clearly. Only respond about history.", + "history_tutor", + "Specialist agent for historical questions"); + ChatClientAgent mathTutor = new(client, + "You provide help with math problems. Explain your reasoning at each step and include examples. Only respond about math.", + "math_tutor", + "Specialist agent for math questions"); + ChatClientAgent triageAgent = new(client, + "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) + .Build(); + + List messages = []; + while (true) + { + Console.Write("Q: "); + messages.Add(new(ChatRole.User, Console.ReadLine()!)); + messages.AddRange(await RunWorkflowAsync(workflow, messages)); + } + + default: + throw new InvalidOperationException("Invalid workflow type."); + } + + static async Task> RunWorkflowAsync(Workflow> workflow, List messages) + { + 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}"); + } + else if (evt is WorkflowCompletedEvent completed) + { + return (List)completed.Data!; + } + } + + return []; + } + } + + /// Creates a translation agent for the specified target language. + private static ChatClientAgent GetTranslationAgent(string targetLanguage, IChatClient chatClient) => + new(chatClient, + $"You are a translation assistant who only responds in {targetLanguage}. Respond to any " + + $"input by outputting the name of the input language and then translating the input to {targetLanguage}."); +} diff --git a/dotnet/src/Microsoft.Agents.Workflows/AgentWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows/AgentWorkflowBuilder.cs new file mode 100644 index 0000000000..3e49919ced --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows/AgentWorkflowBuilder.cs @@ -0,0 +1,497 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.ComponentModel; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Specialized; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; +using Microsoft.Shared.Diagnostics; +#if NET +using System.Security.Cryptography; +#endif + +namespace Microsoft.Agents.Workflows; + +/// +/// Provides utility methods for constructing common patterns of agent workflows. +/// +public static class AgentWorkflowBuilder +{ + /// + /// Builds a composed of a pipeline of agents where the output of one agent is the input to the next. + /// + /// The sequence of agents to compose into a sequential workflow. + /// The built workflow composed of the supplied , in the order in which they were yielded from the source. + public static Workflow> BuildSequential(params IEnumerable agents) + { + Throw.IfNull(agents); + + // Create a builder that chains the agents together in sequence. The workflow simply begins + // with the first agent in the sequence. + WorkflowBuilder? builder = null; + ExecutorIsh? previous = null; + foreach (var agent in agents) + { + AIAgentHostExecutor agentExecutor = new(agent); + + if (builder is null) + { + builder = new WorkflowBuilder(agentExecutor); + } + else + { + Debug.Assert(previous is not null); + builder.AddEdge(previous, agentExecutor); + } + + previous = agentExecutor; + } + + if (previous is null) + { + Throw.ArgumentException(nameof(agents), "At least one agent must be provided to build a sequential workflow."); + } + + // 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()); + + return builder.Build>(); + } + + /// + /// Provides an executor that batches received chat messages that it then publishes as the final result + /// when receiving a . + /// + private sealed class SequentialEndExecutor : Executor + { + private readonly List _pendingMessages = []; + + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => + routeBuilder + .AddHandler((message, context) => this._pendingMessages.Add(message)) + .AddHandler>((messages, _) => this._pendingMessages.AddRange(messages)) + .AddHandler(async (token, context) => + { + var messages = new List(this._pendingMessages); + this._pendingMessages.Clear(); + await context.AddEventAsync(new WorkflowCompletedEvent(messages)).ConfigureAwait(false); + }); + } + + /// + /// Builds a composed of agents that operate concurrently on the same input, + /// aggregating their outputs into a single collection. + /// + /// The set of agents to compose into a concurrent workflow. + /// + /// The aggregation function that accepts a list of the output messages from each and produces + /// a single result list. If , the default behavior is to return a list containing the last message + /// from each agent that produced at least one message. + /// + /// The built workflow composed of the supplied concurrent . + public static Workflow> BuildConcurrent( + IEnumerable agents, + Func>, List>? aggregator = null) + { + Throw.IfNull(agents); + + // A workflow needs a starting executor, so we create one that forwards everything to each agent. + ForwardingExecutor start = new(); + WorkflowBuilder builder = new(start); + + // For each agent, we create an executor to host it and an accumulator to batch up its output messages, + // 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()]; + builder.AddFanOutEdge(start, targets: agentExecutors); + for (int i = 0; i < agentExecutors.Length; i++) + { + builder.AddEdge(agentExecutors[i], accumulators[i]); + } + + // Create the accumulating executor that will gather the results from each agent, and connect + // each agent's accumulator to it. If no aggregation function was provided, we default to returning + // the last message from each agent + aggregator ??= static lists => (from list in lists where list.Count > 0 select list.Last()).ToList(); + ConcurrentEndExecutor end = new(agentExecutors.Length, aggregator); + builder.AddFanInEdge(end, sources: accumulators); + + return builder.Build>(); + } + + /// Creates a new using as the starting agent in the workflow. + /// The agent that will receive inputs provided to the workflow. + /// The builder for creating a workflow based on handoffs. + /// + /// Handoffs between agents are achieved by the current agent invoking an provided to an agent + /// via 's .. + /// The must be capable of understanding those provided. If the agent + /// ignores the tools or is otherwise unable to advertize them to the underlying provider, handoffs will not occur. + /// + public static HandoffsWorkflowBuilder StartHandoffWith(AIAgent initialAgent) + { + Throw.IfNull(initialAgent); + return new(initialAgent); + } + + /// Executor that forwards all relevant messages. + private sealed class ForwardingExecutor : Executor + { + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => + routeBuilder.AddHandler((message, context) => context.SendMessageAsync(message)); + } + + /// + /// Provides an executor that batches received chat messages that it then releases when + /// receiving a . + /// + private sealed class ChatMessageBatchingExecutor : Executor + { + private readonly List _pendingMessages = []; + + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => + routeBuilder + .AddHandler((message, context) => this._pendingMessages.Add(message)) + .AddHandler>((messages, _) => this._pendingMessages.AddRange(messages)) + .AddHandler(async (token, context) => + { + var messages = new List(this._pendingMessages); + this._pendingMessages.Clear(); + + await context.SendMessageAsync(messages).ConfigureAwait(false); + await context.SendMessageAsync(token).ConfigureAwait(false); + }); + } + + /// + /// Provides an executor that accepts the output messages from each of the concurrent agents + /// and produces a result list containing the last message from each. + /// + private sealed class ConcurrentEndExecutor : Executor + { + private readonly int _expectedInputs; + private readonly Func>, List> _aggregator; + private List> _allResults; + private int _remaining; + + public ConcurrentEndExecutor(int expectedInputs, Func>, List> aggregator) + { + this._expectedInputs = expectedInputs; + this._aggregator = Throw.IfNull(aggregator); + + this._allResults = new List>(expectedInputs); + this._remaining = expectedInputs; + } + + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => + routeBuilder.AddHandler>(async (messages, context) => + { + this._allResults.Add(messages); + if (--this._remaining == 0) + { + this._remaining = this._expectedInputs; + var results = this._allResults; + this._allResults = new List>(this._expectedInputs); + await context.AddEventAsync(new WorkflowCompletedEvent(this._aggregator(results))).ConfigureAwait(false); + } + }); + } + + /// + /// Defines the orchestration handoff relationships for all agents in the system. + /// + public sealed class HandoffsWorkflowBuilder + { + private const string FunctionPrefix = "handoff_to_"; + private readonly AIAgent _initialAgent; + private readonly Dictionary> _targets = []; + private readonly Dictionary _allAgents = []; + + /// + /// Initializes a new instance of the class with no handoff relationships. + /// + /// The first agent to be invoked (prior to any handoff). + internal HandoffsWorkflowBuilder(AIAgent initialAgent) + { + this._initialAgent = initialAgent; + this._allAgents.Add(initialAgent.Id, initialAgent); + } + + /// + /// Gets or sets additional instructions to provide to an agent about how to perform handoffs. + /// + /// + /// By default, simple instructions are included. This may be set to to avoid including + /// any additional instructions, or may be customized to provide more specific guidance. + /// + public string? HandoffInstructions { get; set; } = + $""" + You are part of a multi-agent system. Each agent encompasses instructions and tools and can hand off a conversation to another agent + when appropriate. Handoffs are achieved by calling a handoff function, generally named `{FunctionPrefix}`. Handoffs + between agents are handled seamlessly in the background; do not mention or draw attention to these handoffs in your conversation with the user. + """; + + /// + /// Adds handoff relationships from a source agent to one or more target agents. + /// + /// The source agent. + /// The target agents to add as handoff targets for the source agent. + /// The updated instance. + /// The handoff reason for each target is derived from its description or name. + public HandoffsWorkflowBuilder WithHandoff(AIAgent from, IEnumerable to) + { + Throw.IfNull(from); + Throw.IfNull(to); + + foreach (var target in to) + { + if (target is null) + { + Throw.ArgumentNullException(nameof(to), "One or more target agents are null."); + } + + this.WithHandoff(from, target); + } + + return this; + } + + /// + /// Adds a handoff relationship from a source agent to a target agent with a custom handoff reason. + /// + /// The source agent. + /// The target agent. + /// The reason the should hand off to the . + /// The updated instance. + public HandoffsWorkflowBuilder WithHandoff(AIAgent from, AIAgent to, string? handoffReason = null) + { + Throw.IfNull(from); + Throw.IfNull(to); + +#if NET + this._allAgents.TryAdd(from.Id, from); + this._allAgents.TryAdd(to.Id, to); +#else + if (!this._allAgents.ContainsKey(from.Id)) + { + this._allAgents.Add(from.Id, from); + } + + if (!this._allAgents.ContainsKey(to.Id)) + { + this._allAgents.Add(to.Id, to); + } +#endif + + if (!this._targets.TryGetValue(from, out var handoffs)) + { + this._targets[from] = handoffs = []; + } + + if (string.IsNullOrWhiteSpace(handoffReason)) + { + handoffReason = to.Description ?? to.Name ?? (to as ChatClientAgent)?.Instructions; + if (string.IsNullOrWhiteSpace(handoffReason)) + { + Throw.ArgumentException( + nameof(to), + $"The provided target agent '{to.DisplayName}' has no description, name, or instructions, and no handoff description has been provided. " + + "At least one of these is required to register a handoff so that the appropriate target agent can be chosen."); + } + } + + if (!handoffs.Add(new(to, handoffReason))) + { + Throw.InvalidOperationException($"A handoff from agent '{from.DisplayName}' to agent '{to.DisplayName}' has already been registered."); + } + + return this; + } + + /// + /// Builds a composed of agents that operate via handoffs, with the next + /// agent to process messages selected by the current agent. + /// + /// The workflow built based on the handoffs in the builder. + public Workflow> Build() + { + StartHandoffs start = new(); + EndExecutor end = new(); + WorkflowBuilder builder = new(start); + + // Create an AgentExecutor for each again. + Dictionary executors = this._allAgents.ToDictionary(a => a.Key, a => new AgentExecutor(a.Value, this.HandoffInstructions)); + + // Connect the start executor to the initial agent. + builder.AddEdge(start, executors[this._initialAgent.Id]); + + // Initialize each executor with its handoff targets to the other executors. + foreach (var agent in this._allAgents) + { + executors[agent.Key].Initialize(builder, end, executors, + this._targets.TryGetValue(agent.Value, out HashSet? targets) ? targets : []); + } + + // Build the workflow. + return builder.Build>(); + } + + /// Describes a handoff to a specific target . + private readonly record struct HandoffTarget(AIAgent Target, string? Reason = null) + { + public bool Equals(HandoffTarget other) => this.Target.Id == other.Target.Id; + public override int GetHashCode() => this.Target.Id.GetHashCode(); + } + + /// Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token. + private sealed class StartHandoffs : Executor + { + private readonly List _pendingMessages = []; + + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => + routeBuilder + .AddHandler((message, context) => this._pendingMessages.Add(new(ChatRole.User, message))) + .AddHandler((message, context) => this._pendingMessages.Add(message)) + .AddHandler>((messages, _) => this._pendingMessages.AddRange(messages)) + .AddHandler((messages, _) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed + .AddHandler>((messages, _) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed + .AddHandler(async (token, context) => + { + var messages = new List(this._pendingMessages); + this._pendingMessages.Clear(); + await context.SendMessageAsync(new HandoffState(token, null, messages)).ConfigureAwait(false); + }); + } + + /// Executor used at the end of a handoff workflow to raise a final completed event. + private sealed class EndExecutor : Executor + { + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => + routeBuilder.AddHandler((handoff, context) => + context.AddEventAsync(new WorkflowCompletedEvent(handoff.Messages))); + } + + /// Executor used to represent an agent in a handoffs workflow, responding to events. + private sealed class AgentExecutor( + AIAgent agent, + string? instructions) : Executor($"{agent.DisplayName}/{CreateId()}") + { + private static readonly JsonElement s_handoffSchema = AIFunctionFactory.Create( + ([Description("The reason for the handoff")] string? reasonForHandoff) => { }).JsonSchema; + private static readonly AIFunctionDeclaration s_endFunction = AIFunctionFactory.CreateDeclaration( + name: $"end_{CreateId()}", + description: "Invoke this function when all work is completed and no further interactions are required.", + jsonSchema: AIFunctionFactory.Create(() => { }).JsonSchema); + + private readonly AIAgent _agent = agent; + private readonly HashSet _handoffFunctionNames = []; + private readonly ChatClientAgentRunOptions _agentOptions = new() + { + ChatOptions = new() + { + Instructions = instructions, + Tools = [s_endFunction], + } + }; + + public void Initialize( + WorkflowBuilder builder, + Executor end, + Dictionary executors, + IEnumerable handoffs) => + builder.AddSwitch(this, sb => + { + foreach (HandoffTarget handoff in handoffs) + { + var handoffFunc = AIFunctionFactory.CreateDeclaration($"{FunctionPrefix}{CreateId()}", handoff.Reason, s_handoffSchema); + + this._handoffFunctionNames.Add(handoffFunc.Name); + + this._agentOptions.ChatOptions!.Tools!.Add(handoffFunc); + this._agentOptions.ChatOptions.AllowMultipleToolCalls = false; + + sb.AddCase(state => state?.InvokedHandoff == handoffFunc.Name, executors[handoff.Target.Id]); + } + + sb.WithDefault(end); + }); + + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => + routeBuilder.AddHandler(async (handoffState, context) => + { + string? requestedHandoff = null; + List updates = []; + List allMessages = handoffState.Messages; + + while (requestedHandoff is null) + { + updates.Clear(); + await foreach (var update in this._agent.RunStreamingAsync(allMessages, options: this._agentOptions).ConfigureAwait(false)) + { + await AddUpdateAsync(update).ConfigureAwait(false); + for (int i = 0; i < update.Contents.Count; i++) + { + var c = update.Contents[i]; + if (c is FunctionCallContent fcc) + { + 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--; + } + } + } + } + + allMessages.AddRange(updates.ToAgentRunResponse().Messages); + } + + await context.SendMessageAsync(new HandoffState(handoffState.TurnToken, requestedHandoff, allMessages)).ConfigureAwait(false); + + async Task AddUpdateAsync(AgentRunResponseUpdate update) + { + updates.Add(update); + if (handoffState.TurnToken.EmitEvents is true) + { + await context.AddEventAsync(new AgentRunUpdateEvent(this.Id, update)).ConfigureAwait(false); + } + } + }); + } + + private record class HandoffState( + TurnToken TurnToken, + string? InvokedHandoff, + List Messages); + + private static string CreateId() => +#if NET + RandomNumberGenerator.GetString("abcdefghijklmnopqrstuvwxyz0123456789", 24); +#else + Guid.NewGuid().ToString("N"); +#endif + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/MessageRouter.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/MessageRouter.cs index 1083c85c41..cabf5d6db9 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/MessageRouter.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/MessageRouter.cs @@ -20,7 +20,7 @@ internal sealed class MessageRouter { private readonly Dictionary _typedHandlers; private readonly Dictionary _runtimeTypeMap; - private readonly bool _hasCatchall; + private readonly MessageHandlerF? _catchAllHandler; internal MessageRouter(Dictionary handlers) { @@ -28,8 +28,7 @@ internal sealed class MessageRouter this._typedHandlers = handlers; this._runtimeTypeMap = handlers.Keys.ToDictionary(t => new TypeId(t), t => t); - - this._hasCatchall = handlers.ContainsKey(typeof(object)); + this._catchAllHandler = handlers.FirstOrDefault(e => e.Key == typeof(object)).Value; this.IncomingTypes = [.. handlers.Keys]; } @@ -41,7 +40,7 @@ internal sealed class MessageRouter public bool CanHandle(TypeId candidateType) { - return this._hasCatchall || this._runtimeTypeMap.ContainsKey(candidateType); + return this._catchAllHandler is not null || this._runtimeTypeMap.ContainsKey(candidateType); } public async ValueTask RouteMessageAsync(object message, IWorkflowContext context, bool requireRoute = false) @@ -59,7 +58,8 @@ internal sealed class MessageRouter try { - if (this._typedHandlers.TryGetValue(message.GetType(), out MessageHandlerF? handler)) + if (this._typedHandlers.TryGetValue(message.GetType(), out MessageHandlerF? handler) || + (handler = this._catchAllHandler) is not null) { result = await handler(message, context).ConfigureAwait(false); } diff --git a/dotnet/src/Microsoft.Agents.Workflows/Microsoft.Agents.Workflows.csproj b/dotnet/src/Microsoft.Agents.Workflows/Microsoft.Agents.Workflows.csproj index 4af22a5e8b..3c0c8e1a47 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Microsoft.Agents.Workflows.csproj +++ b/dotnet/src/Microsoft.Agents.Workflows/Microsoft.Agents.Workflows.csproj @@ -23,6 +23,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.Workflows/RouteBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows/RouteBuilder.cs index 193a9ac0cc..2cee87fd98 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/RouteBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/RouteBuilder.cs @@ -74,6 +74,32 @@ public class RouteBuilder } } + /// + /// Registers a handler for messages of the specified input type in the workflow route. + /// + /// If a handler for the specified input type already exists and is + /// , the existing handler will not be replaced. Handlers are invoked asynchronously and are + /// expected to complete their processing before the workflow continues. + /// + /// A delegate that processes messages of type within the workflow context. The + /// delegate is invoked for each incoming message of the specified type. + /// to replace any existing handler for the specified input type; otherwise, to preserve the existing handler. + /// The current instance, enabling fluent configuration of additional handlers or route + /// options. + public RouteBuilder AddHandler(Action handler, bool overwrite = false) + { + Throw.IfNull(handler); + + return this.AddHandler(typeof(TInput), WrappedHandlerAsync, overwrite); + + async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx) + { + handler.Invoke((TInput)msg, ctx); + return CallResult.ReturnVoid(); + } + } + /// /// Registers a handler for messages of the specified input type in the workflow route. /// @@ -100,6 +126,32 @@ public class RouteBuilder } } + /// + /// Registers a handler function for messages of the specified input type in the workflow route. + /// + /// If a handler for the given input type already exists, setting to + /// will replace the existing handler; otherwise, an exception may be thrown. The handler + /// receives the input message and workflow context, and returns a result asynchronously. + /// The type of input message the handler will process. + /// The type of result produced by the handler. + /// A function that processes messages of type within the workflow context and returns + /// a representing the asynchronous result. + /// to replace any existing handler for the input type; otherwise, to + /// preserve existing handlers. + /// The current instance, enabling fluent configuration of workflow routes. + public RouteBuilder AddHandler(Func handler, bool overwrite = false) + { + Throw.IfNull(handler); + + return this.AddHandler(typeof(TInput), WrappedHandlerAsync, overwrite); + + async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx) + { + TResult result = handler.Invoke((TInput)msg, ctx); + return CallResult.ReturnResult(result); + } + } + /// /// Registers a handler function for messages of the specified input type in the workflow route. /// diff --git a/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs index 6de8204117..b0b41edeff 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs @@ -26,22 +26,10 @@ internal sealed class AIAgentHostExecutor : Executor this._thread ??= this._agent.GetNewThread(); protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => - routeBuilder.AddHandler(this.QueueMessageAsync) - .AddHandler>(this.QueueMessagesAsync) + routeBuilder.AddHandler((message, _) => this._pendingMessages.Add(message)) + .AddHandler>((messages, _) => this._pendingMessages.AddRange(messages)) .AddHandler(this.TakeTurnAsync); - public ValueTask QueueMessagesAsync(List messages, IWorkflowContext context) - { - this._pendingMessages.AddRange(messages); - return default; - } - - public ValueTask QueueMessageAsync(ChatMessage message, IWorkflowContext context) - { - this._pendingMessages.Add(message); - return default; - } - private const string ThreadStateKey = nameof(_thread); private const string PendingMessagesStateKey = nameof(_pendingMessages); protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellation = default) @@ -56,7 +44,7 @@ internal sealed class AIAgentHostExecutor : Executor Task messagesTask = Task.CompletedTask; if (this._pendingMessages.Count > 0) { - JsonElement messagesValue = this._pendingMessages.SerializeToJson(); + JsonElement messagesValue = this._pendingMessages.Serialize(); messagesTask = context.QueueStateUpdateAsync(PendingMessagesStateKey, messagesValue).AsTask(); } @@ -74,7 +62,7 @@ internal sealed class AIAgentHostExecutor : Executor JsonElement? messagesValue = await context.ReadStateAsync(PendingMessagesStateKey).ConfigureAwait(false); if (messagesValue.HasValue) { - List messages = messagesValue.Value.DeserializeMessageList(); + List messages = messagesValue.Value.DeserializeMessages(); this._pendingMessages.AddRange(messages); } } @@ -122,6 +110,7 @@ internal sealed class AIAgentHostExecutor : Executor } await PublishCurrentMessageAsync().ConfigureAwait(false); + this._pendingMessages.Clear(); await context.SendMessageAsync(token).ConfigureAwait(false); async ValueTask PublishCurrentMessageAsync() diff --git a/dotnet/src/Microsoft.Agents.Workflows/Specialized/WorkflowJsonUtilities.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/WorkflowJsonUtilities.cs deleted file mode 100644 index 5bef957b29..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows/Specialized/WorkflowJsonUtilities.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Linq; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.Workflows.Specialized; - -internal static partial class WorkflowJsonUtilities -{ - public static WorkflowJsonContext Default { get; } = new(); - - [JsonSerializable(typeof(ChatMessage))] - [JsonSerializable(typeof(List))] - internal sealed partial class WorkflowJsonContext : JsonSerializerContext; - - public static JsonElement SerializeToJson(this List messages) => - JsonSerializer.SerializeToElement(messages, Default.ListChatMessage); - - public static JsonElement SerializeToJson(this IEnumerable messages) - => messages.ToList().SerializeToJson(); - - public static List DeserializeMessageList(this JsonElement element) => - element.Deserialize(Default.ListChatMessage) ?? []; -} diff --git a/dotnet/src/Microsoft.Agents.Workflows/WorkflowsJsonUtilities.cs b/dotnet/src/Microsoft.Agents.Workflows/WorkflowsJsonUtilities.cs index ee9335fffc..a9eaf65dd5 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/WorkflowsJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/WorkflowsJsonUtilities.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization; @@ -32,6 +33,12 @@ internal static partial class WorkflowsJsonUtilities /// public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions(); + public static JsonElement Serialize(this IEnumerable messages) => + JsonSerializer.SerializeToElement(messages, DefaultOptions.GetTypeInfo(typeof(IEnumerable))); + + public static List DeserializeMessages(this JsonElement element) => + (List?)element.Deserialize(DefaultOptions.GetTypeInfo(typeof(List))) ?? []; + /// /// Creates default options to use for agents-related serialization. /// @@ -87,6 +94,7 @@ internal static partial class WorkflowsJsonUtilities // For now this is okay, because we never serialize WorkflowEvents into // checkpoints. [JsonSerializable(typeof(JsonElement))] + [ExcludeFromCodeCoverage] internal sealed partial class JsonContext : JsonSerializerContext; } diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AgentRunResponseUpdateExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AgentRunResponseUpdateExtensions.cs index 37ff85ffdd..665aa8c620 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AgentRunResponseUpdateExtensions.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AgentRunResponseUpdateExtensions.cs @@ -152,7 +152,7 @@ public static class AgentRunResponseUpdateExtensions private static void ProcessUpdate(AgentRunResponseUpdate update, AgentRunResponse response) { // If there is no message created yet, or if the last update we saw had a different - // message ID than the newest update, create a new message. + // message ID or role than the newest update, create a new message. ChatMessage message; var isNewMessage = false; if (response.Messages.Count == 0) @@ -165,6 +165,12 @@ public static class AgentRunResponseUpdateExtensions { isNewMessage = true; } + else if (update.Role is { } updateRole + && response.Messages[response.Messages.Count - 1].Role is { } lastRole + && updateRole != lastRole) + { + isNewMessage = true; + } if (isNewMessage) { diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/AgentWorkflowBuilderTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/AgentWorkflowBuilderTests.cs new file mode 100644 index 0000000000..77b355bc9e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/AgentWorkflowBuilderTests.cs @@ -0,0 +1,350 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; + +#pragma warning disable SYSLIB1045 // Use GeneratedRegex +#pragma warning disable RCS1186 // Use Regex instance instead of static method + +namespace Microsoft.Agents.Workflows.UnitTests; + +public class AgentWorkflowBuilderTests +{ + [Fact] + public void BuildSequential_InvalidArguments_Throws() + { + Assert.Throws("agents", () => AgentWorkflowBuilder.BuildSequential(null!)); + Assert.Throws("agents", () => AgentWorkflowBuilder.BuildSequential()); + } + + [Fact] + public void BuildConcurrent_InvalidArguments_Throws() + { + Assert.Throws("agents", () => AgentWorkflowBuilder.BuildConcurrent(null!)); + } + + [Fact] + public void BuildHandoffs_InvalidArguments_Throws() + { + Assert.Throws("initialAgent", () => AgentWorkflowBuilder.StartHandoffWith(null!)); + + var agent = new DoubleEchoAgent("agent"); + var handoffs = AgentWorkflowBuilder.StartHandoffWith(agent); + Assert.NotNull(handoffs); + + Assert.Throws("from", () => handoffs.WithHandoff(null!, new DoubleEchoAgent("a2"))); + Assert.Throws("to", () => handoffs.WithHandoff(new DoubleEchoAgent("a2"), (AIAgent)null!)); + Assert.Throws("to", () => handoffs.WithHandoff(new DoubleEchoAgent("a2"), null!)); + Assert.Throws("to", () => handoffs.WithHandoff(new DoubleEchoAgent("a2"), [null!])); + + var noDescriptionAgent = new ChatClientAgent(new MockChatClient(delegate { return new(); })); + Assert.Throws("to", () => handoffs.WithHandoff(agent, noDescriptionAgent)); + } + + [Fact] + public async Task BuildSequential_AgentsRunInOrderAsync() + { + var workflow = AgentWorkflowBuilder.BuildSequential( + new DoubleEchoAgent("agent1"), + new DoubleEchoAgent("agent2"), + new DoubleEchoAgent("agent3")); + + for (int iter = 0; iter < 3; iter++) + { + (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + + const string Expected = "agent1abcabcagent2agent1abcabcagent1abcabcagent3agent2agent1abcabcagent1abcabcagent2agent1abcabcagent1abcabc"; + Assert.Equal(Expected, updateText); + + Assert.NotNull(result); + Assert.NotNull(Assert.Single(result)); + } + } + + private class DoubleEchoAgent(string name) : AIAgent + { + public override Task RunAsync( + IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public override async IAsyncEnumerable RunStreamingAsync( + IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + string id = Guid.NewGuid().ToString("N"); + var contents = messages.SelectMany(m => m.Contents).ToList(); + + await Task.Yield(); + + yield return new AgentRunResponseUpdate(ChatRole.Assistant, name) { MessageId = id }; + yield return new AgentRunResponseUpdate(ChatRole.Assistant, contents) { MessageId = id }; + yield return new AgentRunResponseUpdate(ChatRole.Assistant, contents) { MessageId = id }; + } + } + + [Fact] + public async Task BuildConcurrent_AgentsRunInParallelAsync() + { + StrongBox> barrier = new(); + StrongBox remaining = new(); + + var workflow = AgentWorkflowBuilder.BuildConcurrent( + [ + new DoubleEchoAgentWithBarrier("agent1", barrier, remaining), + new DoubleEchoAgentWithBarrier("agent2", barrier, remaining), + ]); + + for (int iter = 0; iter < 3; iter++) + { + barrier.Value = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + remaining.Value = 2; + + (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + + Assert.Single(Regex.Matches(updateText, "agent1")); + Assert.Single(Regex.Matches(updateText, "agent2")); + Assert.NotNull(result); + + // TODO: https://github.com/microsoft/agent-framework/issues/784 + // These asserts are flaky until we guarantee message delivery order. + //Assert.Equal(4, Regex.Matches(updateText, "abc").Count); + //Assert.Equal(2, result.Count); + } + } + + [Fact] + public async Task Handoffs_NoTransfers_ResponseServedByOriginalAgentAsync() + { + var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + ChatMessage message = Assert.Single(messages); + Assert.Equal("abc", Assert.IsType(Assert.Single(message.Contents)).Text); + + string? endFunctionName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("end", StringComparison.Ordinal))?.Name; + Assert.NotNull(endFunctionName); + + return new(new ChatMessage(ChatRole.Assistant, + [ + new TextContent("Hello from agent1"), + new FunctionCallContent("call12345", endFunctionName), + ])); + })); + + var workflow = + AgentWorkflowBuilder.StartHandoffWith(initialAgent) + .WithHandoff(initialAgent, new ChatClientAgent(new MockChatClient(delegate + { + Assert.Fail("Should never be invoked."); + return new(); + }), description: "nop")) + .Build(); + + (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + + Assert.Equal("Hello from agent1", updateText); + Assert.NotNull(result); + + Assert.Equal(2, result.Count); + + Assert.Equal(ChatRole.User, result[0].Role); + Assert.Equal("abc", result[0].Text); + + Assert.Equal(ChatRole.Assistant, result[1].Role); + Assert.Equal("Hello from agent1", result[1].Text); + } + + [Fact] + public async Task Handoffs_OneTransfer_ResponseServedBySecondAgentAsync() + { + var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + ChatMessage message = Assert.Single(messages); + Assert.Equal("abc", Assert.IsType(Assert.Single(message.Contents)).Text); + + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); + }), 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"); + + var workflow = + AgentWorkflowBuilder.StartHandoffWith(initialAgent) + .WithHandoff(initialAgent, nextAgent) + .Build(); + + (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + + Assert.Equal("Hello from agent2", updateText); + Assert.NotNull(result); + + Assert.Equal(4, result.Count); + + Assert.Equal(ChatRole.User, result[0].Role); + Assert.Equal("abc", result[0].Text); + + Assert.Equal(ChatRole.Assistant, result[1].Role); + Assert.Equal("", result[1].Text); + Assert.Contains("initialAgent", result[1].AuthorName); + + Assert.Equal(ChatRole.Tool, result[2].Role); + Assert.Contains("initialAgent", result[2].AuthorName); + + Assert.Equal(ChatRole.Assistant, result[3].Role); + Assert.Equal("Hello from agent2", result[3].Text); + Assert.Contains("nextAgent", result[3].AuthorName); + } + + [Fact] + public async Task Handoffs_TwoTransfers_ResponseServedByThirdAgentAsync() + { + var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + ChatMessage message = Assert.Single(messages); + Assert.Equal("abc", Assert.IsType(Assert.Single(message.Contents)).Text); + + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + // Only a handoff function call. + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); + }), name: "initialAgent"); + + var secondAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + // Second agent should receive the conversation so far (including previous assistant + tool messages eventually). + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", transferFuncName)])); + }), 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"); + + var workflow = + AgentWorkflowBuilder.StartHandoffWith(initialAgent) + .WithHandoff(initialAgent, secondAgent) + .WithHandoff(secondAgent, thirdAgent) + .Build(); + + (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + + Assert.Equal("Hello from agent3", updateText); + Assert.NotNull(result); + + // User + (assistant empty + tool) for each of first two agents + final assistant with text. + Assert.Equal(6, result.Count); + + Assert.Equal(ChatRole.User, result[0].Role); + Assert.Equal("abc", result[0].Text); + + Assert.Equal(ChatRole.Assistant, result[1].Role); + Assert.Equal("", result[1].Text); + Assert.Contains("initialAgent", result[1].AuthorName); + + Assert.Equal(ChatRole.Tool, result[2].Role); + Assert.Contains("initialAgent", result[2].AuthorName); + + Assert.Equal(ChatRole.Assistant, result[3].Role); + Assert.Equal("", result[3].Text); + Assert.Contains("secondAgent", result[3].AuthorName); + + Assert.Equal(ChatRole.Tool, result[4].Role); + Assert.Contains("secondAgent", result[4].AuthorName); + + Assert.Equal(ChatRole.Assistant, result[5].Role); + Assert.Equal("Hello from agent3", result[5].Text); + Assert.Contains("thirdAgent", result[5].AuthorName); + } + + private static async Task<(string UpdateText, List? Result)> RunWorkflowAsync( + Workflow> workflow, List input) + { + StringBuilder sb = new(); + + StreamingRun run = await InProcessExecution.StreamAsync(workflow, input); + await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); + + WorkflowCompletedEvent? completed = null; + await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) + { + if (evt is AgentRunUpdateEvent executorComplete) + { + sb.Append(executorComplete.Data); + } + else if (evt is WorkflowCompletedEvent e) + { + completed = e; + break; + } + } + + return (sb.ToString(), completed?.Data as List); + } + + private sealed class DoubleEchoAgentWithBarrier(string name, StrongBox> barrier, StrongBox remaining) : DoubleEchoAgent(name) + { + public override async IAsyncEnumerable RunStreamingAsync( + IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (Interlocked.Decrement(ref remaining.Value) == 0) + { + barrier.Value!.SetResult(true); + } + + await barrier.Value!.Task.ConfigureAwait(false); + + await foreach (var update in base.RunStreamingAsync(messages, thread, options, cancellationToken)) + { + await Task.Yield(); + yield return update; + } + } + } + + private sealed class MockChatClient(Func, ChatOptions?, ChatResponse> responseFactory) : IChatClient + { + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => + Task.FromResult(responseFactory(messages, options)); + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + foreach (var update in (await this.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)).ToChatResponseUpdates()) + { + yield return update; + } + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + public void Dispose() { } + } +} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/AgentRunResponseUpdateExtensionsTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/AgentRunResponseUpdateExtensionsTests.cs index e7e38e4c38..3c2364ab58 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/AgentRunResponseUpdateExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/AgentRunResponseUpdateExtensionsTests.cs @@ -63,18 +63,29 @@ public class AgentRunResponseUpdateExtensionsTests Assert.Equal("someResponse", response.ResponseId); Assert.Equal(new DateTimeOffset(2, 2, 3, 4, 5, 6, TimeSpan.Zero), response.CreatedAt); - ChatMessage message = response.Messages.Single(); + Assert.Equal(2, response.Messages.Count); + + ChatMessage message = response.Messages[0]; Assert.Equal("12345", message.MessageId); - Assert.Equal(new ChatRole("human"), message.Role); - Assert.Equal("Someone", message.AuthorName); + Assert.Equal(ChatRole.Assistant, message.Role); + Assert.Null(message.AuthorName); Assert.Null(message.AdditionalProperties); + Assert.Single(message.Contents); + Assert.Equal("Hello", Assert.IsType(message.Contents[0]).Text); + + message = response.Messages[1]; + Assert.Null(message.MessageId); + Assert.Equal(new("human"), message.Role); + Assert.Equal("Someone", message.AuthorName); + Assert.Single(message.Contents); + Assert.Equal(", world!", Assert.IsType(message.Contents[0]).Text); Assert.NotNull(response.AdditionalProperties); Assert.Equal(2, response.AdditionalProperties.Count); Assert.Equal("b", response.AdditionalProperties["a"]); Assert.Equal("d", response.AdditionalProperties["c"]); - Assert.Equal("Hello, world!", response.Text); + Assert.Equal("Hello" + Environment.NewLine + ", world!", response.Text); } [Theory]