Add AgentWorkflowBuilder with Sequential, Concurrent, Handoff (#792)

This commit is contained in:
Stephen Toub
2025-09-19 15:16:07 -04:00
committed by GitHub
Unverified
parent aba094b5cf
commit 52790b9f6a
13 changed files with 1068 additions and 53 deletions
+1
View File
@@ -109,6 +109,7 @@
<Project Path="samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/01_ExecutorsAndEdges.csproj" />
<Project Path="samples/GettingStarted/Workflows/_Foundational/02_Streaming/02_Streaming.csproj" />
<Project Path="samples/GettingStarted/Workflows/_Foundational/03_AgentsInWorkflows/03_AgentsInWorkflows.csproj" />
<Project Path="samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/04_AgentWorkflowPatterns.csproj" />
</Folder>
<Folder Name="/Samples/SemanticKernelMigration/" />
<Folder Name="/Samples/SemanticKernelMigration/AzureAIFoundry/">
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.Workflows\Microsoft.Agents.Workflows.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Extensions.AI.Agents.AzureAI\Microsoft.Extensions.AI.Agents.AzureAI.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Extensions.AI.Agents\Microsoft.Extensions.AI.Agents.csproj" />
</ItemGroup>
</Project>
@@ -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;
/// <summary>
/// This sample introduces the use of AI agents as executors within a workflow,
/// using <see cref="AgentWorkflowBuilder"/> to compose the agents into one of
/// several common patterns.
/// </summary>
/// <remarks>
/// Pre-requisites:
/// - An Azure OpenAI chat completion deployment must be configured.
/// </remarks>
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<ChatMessage> 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<List<ChatMessage>> RunWorkflowAsync(Workflow<List<ChatMessage>> workflow, List<ChatMessage> 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<ChatMessage>)completed.Data!;
}
}
return [];
}
}
/// <summary>Creates a translation agent for the specified target language.</summary>
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}.");
}
@@ -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;
/// <summary>
/// Provides utility methods for constructing common patterns of agent workflows.
/// </summary>
public static class AgentWorkflowBuilder
{
/// <summary>
/// Builds a <see cref="Workflow{T}"/> composed of a pipeline of agents where the output of one agent is the input to the next.
/// </summary>
/// <param name="agents">The sequence of agents to compose into a sequential workflow.</param>
/// <returns>The built workflow composed of the supplied <paramref name="agents"/>, in the order in which they were yielded from the source.</returns>
public static Workflow<List<ChatMessage>> BuildSequential(params IEnumerable<AIAgent> 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<List<ChatMessage>>();
}
/// <summary>
/// Provides an executor that batches received chat messages that it then publishes as the final result
/// when receiving a <see cref="TurnToken"/>.
/// </summary>
private sealed class SequentialEndExecutor : Executor
{
private readonly List<ChatMessage> _pendingMessages = [];
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder
.AddHandler<ChatMessage>((message, context) => this._pendingMessages.Add(message))
.AddHandler<List<ChatMessage>>((messages, _) => this._pendingMessages.AddRange(messages))
.AddHandler<TurnToken>(async (token, context) =>
{
var messages = new List<ChatMessage>(this._pendingMessages);
this._pendingMessages.Clear();
await context.AddEventAsync(new WorkflowCompletedEvent(messages)).ConfigureAwait(false);
});
}
/// <summary>
/// Builds a <see cref="Workflow{T}"/> composed of agents that operate concurrently on the same input,
/// aggregating their outputs into a single collection.
/// </summary>
/// <param name="agents">The set of agents to compose into a concurrent workflow.</param>
/// <param name="aggregator">
/// The aggregation function that accepts a list of the output messages from each <paramref name="agents"/> and produces
/// a single result list. If <see langword="null"/>, the default behavior is to return a list containing the last message
/// from each agent that produced at least one message.
/// </param>
/// <returns>The built workflow composed of the supplied concurrent <paramref name="agents"/>.</returns>
public static Workflow<List<ChatMessage>> BuildConcurrent(
IEnumerable<AIAgent> agents,
Func<IList<List<ChatMessage>>, List<ChatMessage>>? 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<List<ChatMessage>>();
}
/// <summary>Creates a new <see cref="HandoffsWorkflowBuilder"/> using <paramref name="initialAgent"/> as the starting agent in the workflow.</summary>
/// <param name="initialAgent">The agent that will receive inputs provided to the workflow.</param>
/// <returns>The builder for creating a workflow based on handoffs.</returns>
/// <remarks>
/// Handoffs between agents are achieved by the current agent invoking an <see cref="AITool"/> provided to an agent
/// via <see cref="ChatClientAgentOptions"/>'s <see cref="ChatClientAgentOptions.ChatOptions"/>.<see cref="ChatOptions.Tools"/>.
/// The <see cref="AIAgent"/> must be capable of understanding those <see cref="AgentRunOptions"/> provided. If the agent
/// ignores the tools or is otherwise unable to advertize them to the underlying provider, handoffs will not occur.
/// </remarks>
public static HandoffsWorkflowBuilder StartHandoffWith(AIAgent initialAgent)
{
Throw.IfNull(initialAgent);
return new(initialAgent);
}
/// <summary>Executor that forwards all relevant messages.</summary>
private sealed class ForwardingExecutor : Executor
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<object>((message, context) => context.SendMessageAsync(message));
}
/// <summary>
/// Provides an executor that batches received chat messages that it then releases when
/// receiving a <see cref="TurnToken"/>.
/// </summary>
private sealed class ChatMessageBatchingExecutor : Executor
{
private readonly List<ChatMessage> _pendingMessages = [];
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder
.AddHandler<ChatMessage>((message, context) => this._pendingMessages.Add(message))
.AddHandler<List<ChatMessage>>((messages, _) => this._pendingMessages.AddRange(messages))
.AddHandler<TurnToken>(async (token, context) =>
{
var messages = new List<ChatMessage>(this._pendingMessages);
this._pendingMessages.Clear();
await context.SendMessageAsync(messages).ConfigureAwait(false);
await context.SendMessageAsync(token).ConfigureAwait(false);
});
}
/// <summary>
/// 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.
/// </summary>
private sealed class ConcurrentEndExecutor : Executor
{
private readonly int _expectedInputs;
private readonly Func<IList<List<ChatMessage>>, List<ChatMessage>> _aggregator;
private List<List<ChatMessage>> _allResults;
private int _remaining;
public ConcurrentEndExecutor(int expectedInputs, Func<IList<List<ChatMessage>>, List<ChatMessage>> aggregator)
{
this._expectedInputs = expectedInputs;
this._aggregator = Throw.IfNull(aggregator);
this._allResults = new List<List<ChatMessage>>(expectedInputs);
this._remaining = expectedInputs;
}
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<List<ChatMessage>>(async (messages, context) =>
{
this._allResults.Add(messages);
if (--this._remaining == 0)
{
this._remaining = this._expectedInputs;
var results = this._allResults;
this._allResults = new List<List<ChatMessage>>(this._expectedInputs);
await context.AddEventAsync(new WorkflowCompletedEvent(this._aggregator(results))).ConfigureAwait(false);
}
});
}
/// <summary>
/// Defines the orchestration handoff relationships for all agents in the system.
/// </summary>
public sealed class HandoffsWorkflowBuilder
{
private const string FunctionPrefix = "handoff_to_";
private readonly AIAgent _initialAgent;
private readonly Dictionary<AIAgent, HashSet<HandoffTarget>> _targets = [];
private readonly Dictionary<string, AIAgent> _allAgents = [];
/// <summary>
/// Initializes a new instance of the <see cref="HandoffsWorkflowBuilder"/> class with no handoff relationships.
/// </summary>
/// <param name="initialAgent">The first agent to be invoked (prior to any handoff).</param>
internal HandoffsWorkflowBuilder(AIAgent initialAgent)
{
this._initialAgent = initialAgent;
this._allAgents.Add(initialAgent.Id, initialAgent);
}
/// <summary>
/// Gets or sets additional instructions to provide to an agent about how to perform handoffs.
/// </summary>
/// <remarks>
/// By default, simple instructions are included. This may be set to <see langword="null"/> to avoid including
/// any additional instructions, or may be customized to provide more specific guidance.
/// </remarks>
public string? HandoffInstructions { get; set; } =
$"""
You are part of a multi-agent system. Each agent encompasses instructions and tools and can hand off a conversation to another agent
when appropriate. Handoffs are achieved by calling a handoff function, generally named `{FunctionPrefix}<agent_id>`. Handoffs
between agents are handled seamlessly in the background; do not mention or draw attention to these handoffs in your conversation with the user.
""";
/// <summary>
/// Adds handoff relationships from a source agent to one or more target agents.
/// </summary>
/// <param name="from">The source agent.</param>
/// <param name="to">The target agents to add as handoff targets for the source agent.</param>
/// <returns>The updated <see cref="HandoffsWorkflowBuilder"/> instance.</returns>
/// <remarks>The handoff reason for each target is derived from its description or name.</remarks>
public HandoffsWorkflowBuilder WithHandoff(AIAgent from, IEnumerable<AIAgent> to)
{
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;
}
/// <summary>
/// Adds a handoff relationship from a source agent to a target agent with a custom handoff reason.
/// </summary>
/// <param name="from">The source agent.</param>
/// <param name="to">The target agent.</param>
/// <param name="handoffReason">The reason the <paramref name="from"/> should hand off to the <paramref name="to"/>.</param>
/// <returns>The updated <see cref="HandoffsWorkflowBuilder"/> instance.</returns>
public HandoffsWorkflowBuilder WithHandoff(AIAgent from, AIAgent to, string? handoffReason = null)
{
Throw.IfNull(from);
Throw.IfNull(to);
#if NET
this._allAgents.TryAdd(from.Id, from);
this._allAgents.TryAdd(to.Id, to);
#else
if (!this._allAgents.ContainsKey(from.Id))
{
this._allAgents.Add(from.Id, from);
}
if (!this._allAgents.ContainsKey(to.Id))
{
this._allAgents.Add(to.Id, to);
}
#endif
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;
}
/// <summary>
/// Builds a <see cref="Workflow{T}"/> composed of agents that operate via handoffs, with the next
/// agent to process messages selected by the current agent.
/// </summary>
/// <returns>The workflow built based on the handoffs in the builder.</returns>
public Workflow<List<ChatMessage>> Build()
{
StartHandoffs start = new();
EndExecutor end = new();
WorkflowBuilder builder = new(start);
// Create an AgentExecutor for each again.
Dictionary<string, AgentExecutor> executors = this._allAgents.ToDictionary(a => a.Key, a => new AgentExecutor(a.Value, this.HandoffInstructions));
// 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<HandoffTarget>? targets) ? targets : []);
}
// Build the workflow.
return builder.Build<List<ChatMessage>>();
}
/// <summary>Describes a handoff to a specific target <see cref="AIAgent"/>.</summary>
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();
}
/// <summary>Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token.</summary>
private sealed class StartHandoffs : Executor
{
private readonly List<ChatMessage> _pendingMessages = [];
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder
.AddHandler<string>((message, context) => this._pendingMessages.Add(new(ChatRole.User, message)))
.AddHandler<ChatMessage>((message, context) => this._pendingMessages.Add(message))
.AddHandler<IEnumerable<ChatMessage>>((messages, _) => this._pendingMessages.AddRange(messages))
.AddHandler<ChatMessage[]>((messages, _) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed
.AddHandler<List<ChatMessage>>((messages, _) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed
.AddHandler<TurnToken>(async (token, context) =>
{
var messages = new List<ChatMessage>(this._pendingMessages);
this._pendingMessages.Clear();
await context.SendMessageAsync(new HandoffState(token, null, messages)).ConfigureAwait(false);
});
}
/// <summary>Executor used at the end of a handoff workflow to raise a final completed event.</summary>
private sealed class EndExecutor : Executor
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<HandoffState>((handoff, context) =>
context.AddEventAsync(new WorkflowCompletedEvent(handoff.Messages)));
}
/// <summary>Executor used to represent an agent in a handoffs workflow, responding to <see cref="HandoffState"/> events.</summary>
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<string> _handoffFunctionNames = [];
private readonly ChatClientAgentRunOptions _agentOptions = new()
{
ChatOptions = new()
{
Instructions = instructions,
Tools = [s_endFunction],
}
};
public void Initialize(
WorkflowBuilder builder,
Executor end,
Dictionary<string, AgentExecutor> executors,
IEnumerable<HandoffTarget> 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<HandoffState>(state => state?.InvokedHandoff == handoffFunc.Name, executors[handoff.Target.Id]);
}
sb.WithDefault(end);
});
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<HandoffState>(async (handoffState, context) =>
{
string? requestedHandoff = null;
List<AgentRunResponseUpdate> updates = [];
List<ChatMessage> 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<ChatMessage> Messages);
private static string CreateId() =>
#if NET
RandomNumberGenerator.GetString("abcdefghijklmnopqrstuvwxyz0123456789", 24);
#else
Guid.NewGuid().ToString("N");
#endif
}
}
@@ -20,7 +20,7 @@ internal sealed class MessageRouter
{
private readonly Dictionary<Type, MessageHandlerF> _typedHandlers;
private readonly Dictionary<TypeId, Type> _runtimeTypeMap;
private readonly bool _hasCatchall;
private readonly MessageHandlerF? _catchAllHandler;
internal MessageRouter(Dictionary<Type, MessageHandlerF> 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<CallResult?> 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);
}
@@ -23,6 +23,7 @@
<ItemGroup>
<ProjectReference Include="..\Microsoft.Extensions.AI.Agents.Abstractions\Microsoft.Extensions.AI.Agents.Abstractions.csproj" />
<ProjectReference Include="..\Microsoft.Extensions.AI.Agents\Microsoft.Extensions.AI.Agents.csproj" />
<ProjectReference Include="..\Microsoft.Extensions.AI.Agents.Runtime\Microsoft.Extensions.AI.Agents.Runtime.csproj" />
<ProjectReference Include="..\Microsoft.Extensions.AI.Agents.Runtime.Abstractions\Microsoft.Extensions.AI.Agents.Runtime.Abstractions.csproj" />
</ItemGroup>
@@ -74,6 +74,32 @@ public class RouteBuilder
}
}
/// <summary>
/// Registers a handler for messages of the specified input type in the workflow route.
/// </summary>
/// <remarks>If a handler for the specified input type already exists and <paramref name="overwrite"/> is
/// <see langword="false"/>, the existing handler will not be replaced. Handlers are invoked asynchronously and are
/// expected to complete their processing before the workflow continues.</remarks>
/// <typeparam name="TInput"></typeparam>
/// <param name="handler">A delegate that processes messages of type <typeparamref name="TInput"/> within the workflow context. The
/// delegate is invoked for each incoming message of the specified type.</param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the specified input type; otherwise, <see
/// langword="false"/> to preserve the existing handler.</param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of additional handlers or route
/// options.</returns>
public RouteBuilder AddHandler<TInput>(Action<TInput, IWorkflowContext> handler, bool overwrite = false)
{
Throw.IfNull(handler);
return this.AddHandler(typeof(TInput), WrappedHandlerAsync, overwrite);
async ValueTask<CallResult> WrappedHandlerAsync(object msg, IWorkflowContext ctx)
{
handler.Invoke((TInput)msg, ctx);
return CallResult.ReturnVoid();
}
}
/// <summary>
/// Registers a handler for messages of the specified input type in the workflow route.
/// </summary>
@@ -100,6 +126,32 @@ public class RouteBuilder
}
}
/// <summary>
/// Registers a handler function for messages of the specified input type in the workflow route.
/// </summary>
/// <remarks>If a handler for the given input type already exists, setting <paramref name="overwrite"/> to
/// <see langword="true"/> 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.</remarks>
/// <typeparam name="TInput">The type of input message the handler will process.</typeparam>
/// <typeparam name="TResult">The type of result produced by the handler.</typeparam>
/// <param name="handler">A function that processes messages of type <typeparamref name="TInput"/> within the workflow context and returns
/// a <see cref="ValueTask{TResult}"/> representing the asynchronous result.</param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
/// preserve existing handlers.</param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
public RouteBuilder AddHandler<TInput, TResult>(Func<TInput, IWorkflowContext, TResult> handler, bool overwrite = false)
{
Throw.IfNull(handler);
return this.AddHandler(typeof(TInput), WrappedHandlerAsync, overwrite);
async ValueTask<CallResult> WrappedHandlerAsync(object msg, IWorkflowContext ctx)
{
TResult result = handler.Invoke((TInput)msg, ctx);
return CallResult.ReturnResult(result);
}
}
/// <summary>
/// Registers a handler function for messages of the specified input type in the workflow route.
/// </summary>
@@ -26,22 +26,10 @@ internal sealed class AIAgentHostExecutor : Executor
this._thread ??= this._agent.GetNewThread();
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<ChatMessage>(this.QueueMessageAsync)
.AddHandler<List<ChatMessage>>(this.QueueMessagesAsync)
routeBuilder.AddHandler<ChatMessage>((message, _) => this._pendingMessages.Add(message))
.AddHandler<List<ChatMessage>>((messages, _) => this._pendingMessages.AddRange(messages))
.AddHandler<TurnToken>(this.TakeTurnAsync);
public ValueTask QueueMessagesAsync(List<ChatMessage> 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<JsonElement?>(PendingMessagesStateKey).ConfigureAwait(false);
if (messagesValue.HasValue)
{
List<ChatMessage> messages = messagesValue.Value.DeserializeMessageList();
List<ChatMessage> 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()
@@ -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<ChatMessage>))]
internal sealed partial class WorkflowJsonContext : JsonSerializerContext;
public static JsonElement SerializeToJson(this List<ChatMessage> messages) =>
JsonSerializer.SerializeToElement(messages, Default.ListChatMessage);
public static JsonElement SerializeToJson(this IEnumerable<ChatMessage> messages)
=> messages.ToList().SerializeToJson();
public static List<ChatMessage> DeserializeMessageList(this JsonElement element) =>
element.Deserialize(Default.ListChatMessage) ?? [];
}
@@ -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
/// </remarks>
public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
public static JsonElement Serialize(this IEnumerable<ChatMessage> messages) =>
JsonSerializer.SerializeToElement(messages, DefaultOptions.GetTypeInfo(typeof(IEnumerable<ChatMessage>)));
public static List<ChatMessage> DeserializeMessages(this JsonElement element) =>
(List<ChatMessage>?)element.Deserialize(DefaultOptions.GetTypeInfo(typeof(List<ChatMessage>))) ?? [];
/// <summary>
/// Creates default options to use for agents-related serialization.
/// </summary>
@@ -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;
}
@@ -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)
{
@@ -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<ArgumentNullException>("agents", () => AgentWorkflowBuilder.BuildSequential(null!));
Assert.Throws<ArgumentException>("agents", () => AgentWorkflowBuilder.BuildSequential());
}
[Fact]
public void BuildConcurrent_InvalidArguments_Throws()
{
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.BuildConcurrent(null!));
}
[Fact]
public void BuildHandoffs_InvalidArguments_Throws()
{
Assert.Throws<ArgumentNullException>("initialAgent", () => AgentWorkflowBuilder.StartHandoffWith(null!));
var agent = new DoubleEchoAgent("agent");
var handoffs = AgentWorkflowBuilder.StartHandoffWith(agent);
Assert.NotNull(handoffs);
Assert.Throws<ArgumentNullException>("from", () => handoffs.WithHandoff(null!, new DoubleEchoAgent("a2")));
Assert.Throws<ArgumentNullException>("to", () => handoffs.WithHandoff(new DoubleEchoAgent("a2"), (AIAgent)null!));
Assert.Throws<ArgumentNullException>("to", () => handoffs.WithHandoff(new DoubleEchoAgent("a2"), null!));
Assert.Throws<ArgumentNullException>("to", () => handoffs.WithHandoff(new DoubleEchoAgent("a2"), [null!]));
var noDescriptionAgent = new ChatClientAgent(new MockChatClient(delegate { return new(); }));
Assert.Throws<ArgumentException>("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<ChatMessage>? 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<AgentRunResponse> RunAsync(
IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
string id = Guid.NewGuid().ToString("N");
var contents = messages.SelectMany(m => m.Contents).ToList();
await Task.Yield();
yield return new AgentRunResponseUpdate(ChatRole.Assistant, name) { MessageId = id };
yield return new AgentRunResponseUpdate(ChatRole.Assistant, contents) { MessageId = id };
yield return new AgentRunResponseUpdate(ChatRole.Assistant, contents) { MessageId = id };
}
}
[Fact]
public async Task BuildConcurrent_AgentsRunInParallelAsync()
{
StrongBox<TaskCompletionSource<bool>> barrier = new();
StrongBox<int> 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<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
remaining.Value = 2;
(string updateText, List<ChatMessage>? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
Assert.Single(Regex.Matches(updateText, "agent1"));
Assert.Single(Regex.Matches(updateText, "agent2"));
Assert.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<TextContent>(Assert.Single(message.Contents)).Text);
string? endFunctionName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("end", StringComparison.Ordinal))?.Name;
Assert.NotNull(endFunctionName);
return new(new ChatMessage(ChatRole.Assistant,
[
new TextContent("Hello from agent1"),
new FunctionCallContent("call12345", endFunctionName),
]));
}));
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<ChatMessage>? 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<TextContent>(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<ChatMessage>? 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<TextContent>(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<ChatMessage>? 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<ChatMessage>? Result)> RunWorkflowAsync(
Workflow<List<ChatMessage>> workflow, List<ChatMessage> 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<ChatMessage>);
}
private sealed class DoubleEchoAgentWithBarrier(string name, StrongBox<TaskCompletionSource<bool>> barrier, StrongBox<int> remaining) : DoubleEchoAgent(name)
{
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> 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<IEnumerable<ChatMessage>, ChatOptions?, ChatResponse> responseFactory) : IChatClient
{
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default) =>
Task.FromResult(responseFactory(messages, options));
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> 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() { }
}
}
@@ -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<TextContent>(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<TextContent>(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]