mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Add AgentWorkflowBuilder with Sequential, Concurrent, Handoff (#792)
This commit is contained in:
committed by
GitHub
Unverified
parent
aba094b5cf
commit
52790b9f6a
@@ -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/">
|
||||
|
||||
+23
@@ -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>
|
||||
+104
@@ -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;
|
||||
}
|
||||
|
||||
+7
-1
@@ -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() { }
|
||||
}
|
||||
}
|
||||
+15
-4
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user