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]