mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Add AgentWorkflowBuilder group chat (#861)
* Add AgentWorkflowBuilder group chat
And fix a variety of issues along the way:
- Use DateTime{Offset}.UtcNow rather than Now
- AIAgentHostExecutor shouldn't be publishing empty messages
- Sequential workflows should be flowing all history and not just the output from the previous agent as the input into the next agent
- Renamed some of the new agent workflow methods... still not super happy with the shape, though
- Simplified handoffs builder, e.g. using a hashset with a custom comparer instead of a dictionary
- Improved multi-service use by trying to change assistant->user role for messages created by other agents
- Changed MessageMerger to rely on M.E.AI's coalescing more and to avoid empty contents / text
- Ensured that messages from ChatClientAgent include MessageId and CreatedAt timestamps
- Avoided including instructions for agents in a handoff workflow that don't have any handoffs
- Removed the unnecessary end function in handoffs
- Improved naming of executors to include agent name for debuggability
- Use "N" formatting with Guid.ToString everywhere, to avoid the unnecessary extra dash character which is also not valid in various places (like function tool names)
- Replace `params T[]` with `params IEnumerable<T>` to make public APIs more flexible in what they consume
* Address feedback
- Fix unintentional provider change in sample
This commit is contained in:
committed by
GitHub
Unverified
parent
7c70c33157
commit
03ef7f054f
@@ -10,8 +10,7 @@ namespace Microsoft.Agents.Orchestration;
|
||||
/// </summary>
|
||||
internal sealed class OrchestratingAgentThread : InMemoryAgentThread
|
||||
{
|
||||
internal OrchestratingAgentThread()
|
||||
: base() { }
|
||||
internal OrchestratingAgentThread() { }
|
||||
|
||||
internal OrchestratingAgentThread(JsonElement serializedThreadState, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
: base(serializedThreadState, jsonSerializerOptions) { }
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.AI.Agents;
|
||||
|
||||
@@ -18,19 +17,4 @@ internal static class AIAgentsAbstractionsExtensions
|
||||
MessageId = update.MessageId,
|
||||
RawRepresentation = update.RawRepresentation ?? update,
|
||||
};
|
||||
|
||||
public static ChatMessage UpdateWith(this ChatMessage baseMessage, AgentRunResponseUpdate update)
|
||||
{
|
||||
Debug.Assert(update.MessageId is null || baseMessage.MessageId == update.MessageId);
|
||||
|
||||
return new()
|
||||
{
|
||||
AuthorName = update.AuthorName ?? baseMessage.AuthorName,
|
||||
Contents = [.. baseMessage.Contents, .. update.Contents],
|
||||
Role = update.Role ?? baseMessage.Role,
|
||||
CreatedAt = update.CreatedAt ?? baseMessage.CreatedAt,
|
||||
MessageId = baseMessage.MessageId,
|
||||
RawRepresentation = update.RawRepresentation ?? baseMessage.RawRepresentation ?? update,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,25 +2,24 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.Workflows.Specialized;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.AI.Agents;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
#if NET
|
||||
using System.Security.Cryptography;
|
||||
#endif
|
||||
|
||||
namespace Microsoft.Agents.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Provides utility methods for constructing common patterns of agent workflows.
|
||||
/// Provides utility methods for constructing common patterns of workflows composed of agents.
|
||||
/// </summary>
|
||||
public static class AgentWorkflowBuilder
|
||||
public static partial class AgentWorkflowBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a <see cref="Workflow{T}"/> composed of a pipeline of agents where the output of one agent is the input to the next.
|
||||
@@ -37,7 +36,7 @@ public static class AgentWorkflowBuilder
|
||||
ExecutorIsh? previous = null;
|
||||
foreach (var agent in agents)
|
||||
{
|
||||
AIAgentHostExecutor agentExecutor = new(agent);
|
||||
AgentRunStreamingExecutor agentExecutor = new(agent, includeInputInOutput: true);
|
||||
|
||||
if (builder is null)
|
||||
{
|
||||
@@ -60,31 +59,11 @@ public static class AgentWorkflowBuilder
|
||||
// Add an ending executor that batches up all messages from the last agent
|
||||
// so that it's published as a single list result.
|
||||
Debug.Assert(builder is not null);
|
||||
builder.AddEdge(previous, new SequentialEndExecutor());
|
||||
builder.AddEdge(previous, new ConvertMessageListToCompletedEventExecutor());
|
||||
|
||||
return builder.Build<List<ChatMessage>>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides an executor that batches received chat messages that it then publishes as the final result
|
||||
/// when receiving a <see cref="TurnToken"/>.
|
||||
/// </summary>
|
||||
private sealed class SequentialEndExecutor : Executor
|
||||
{
|
||||
private readonly List<ChatMessage> _pendingMessages = [];
|
||||
|
||||
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
|
||||
routeBuilder
|
||||
.AddHandler<ChatMessage>((message, context) => this._pendingMessages.Add(message))
|
||||
.AddHandler<List<ChatMessage>>((messages, _) => this._pendingMessages.AddRange(messages))
|
||||
.AddHandler<TurnToken>(async (token, context) =>
|
||||
{
|
||||
var messages = new List<ChatMessage>(this._pendingMessages);
|
||||
this._pendingMessages.Clear();
|
||||
await context.AddEventAsync(new WorkflowCompletedEvent(messages)).ConfigureAwait(false);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="Workflow{T}"/> composed of agents that operate concurrently on the same input,
|
||||
/// aggregating their outputs into a single collection.
|
||||
@@ -110,8 +89,8 @@ public static class AgentWorkflowBuilder
|
||||
// so that the final accumulator receives a single list of messages from each agent. Otherwise, the
|
||||
// accumulator would not be able to determine what came from what agent, as there's currently no
|
||||
// provenance tracking exposed in the workflow context passed to a handler.
|
||||
ExecutorIsh[] agentExecutors = (from agent in agents select (ExecutorIsh)agent).ToArray();
|
||||
ExecutorIsh[] accumulators = [.. from agent in agentExecutors select (ExecutorIsh)new ChatMessageBatchingExecutor()];
|
||||
ExecutorIsh[] agentExecutors = (from agent in agents select (ExecutorIsh)new AgentRunStreamingExecutor(agent, includeInputInOutput: false)).ToArray();
|
||||
ExecutorIsh[] accumulators = [.. from agent in agentExecutors select (ExecutorIsh)new BatchChatMessagesToListExecutor()];
|
||||
builder.AddFanOutEdge(start, targets: agentExecutors);
|
||||
for (int i = 0; i < agentExecutors.Length; i++)
|
||||
{
|
||||
@@ -137,13 +116,96 @@ public static class AgentWorkflowBuilder
|
||||
/// The <see cref="AIAgent"/> must be capable of understanding those <see cref="AgentRunOptions"/> provided. If the agent
|
||||
/// ignores the tools or is otherwise unable to advertize them to the underlying provider, handoffs will not occur.
|
||||
/// </remarks>
|
||||
public static HandoffsWorkflowBuilder StartHandoffWith(AIAgent initialAgent)
|
||||
public static HandoffsWorkflowBuilder CreateHandoffBuilderWith(AIAgent initialAgent)
|
||||
{
|
||||
Throw.IfNull(initialAgent);
|
||||
return new(initialAgent);
|
||||
}
|
||||
|
||||
/// <summary>Executor that forwards all relevant messages.</summary>
|
||||
/// <summary>Creates a new <see cref="GroupChatWorkflowBuilder"/> with <paramref name="managerFactory"/>.</summary>
|
||||
/// <param name="managerFactory">
|
||||
/// Function that will create the <see cref="GroupChatManager"/> for the workflow instance. The manager will be
|
||||
/// provided with the set of agents that will participate in the group chat.
|
||||
/// </param>
|
||||
/// <returns>The builder for creating a workflow based on handoffs.</returns>
|
||||
/// <remarks>
|
||||
/// Handoffs between agents are achieved by the current agent invoking an <see cref="AITool"/> provided to an agent
|
||||
/// via <see cref="ChatClientAgentOptions"/>'s <see cref="ChatClientAgentOptions.ChatOptions"/>.<see cref="ChatOptions.Tools"/>.
|
||||
/// The <see cref="AIAgent"/> must be capable of understanding those <see cref="AgentRunOptions"/> provided. If the agent
|
||||
/// ignores the tools or is otherwise unable to advertize them to the underlying provider, handoffs will not occur.
|
||||
/// </remarks>
|
||||
public static GroupChatWorkflowBuilder CreateGroupChatBuilderWith(Func<IReadOnlyList<AIAgent>, GroupChatManager> managerFactory)
|
||||
{
|
||||
Throw.IfNull(managerFactory);
|
||||
return new GroupChatWorkflowBuilder(managerFactory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executor that runs the agent and forwards all messages, input and output, to the next executor.
|
||||
/// </summary>
|
||||
private sealed class AgentRunStreamingExecutor(AIAgent agent, bool includeInputInOutput) : Executor(GetDescriptiveIdFromAgent(agent))
|
||||
{
|
||||
private readonly List<ChatMessage> _pendingMessages = [];
|
||||
|
||||
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
|
||||
routeBuilder
|
||||
.AddHandler<string>((message, context) => this._pendingMessages.Add(new(ChatRole.User, message)))
|
||||
.AddHandler<ChatMessage>((message, context) => this._pendingMessages.Add(message))
|
||||
.AddHandler<IEnumerable<ChatMessage>>((messages, _) => this._pendingMessages.AddRange(messages))
|
||||
.AddHandler<ChatMessage[]>((messages, _) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed
|
||||
.AddHandler<List<ChatMessage>>((messages, _) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed
|
||||
.AddHandler<TurnToken>(async (token, context) =>
|
||||
{
|
||||
List<ChatMessage> messages = [.. this._pendingMessages];
|
||||
this._pendingMessages.Clear();
|
||||
|
||||
List<ChatMessage>? roleChanged = ChangeAssistantToUserForOtherParticipants(agent.DisplayName, messages);
|
||||
|
||||
List<AgentRunResponseUpdate> updates = [];
|
||||
await foreach (var update in agent.RunStreamingAsync(messages).ConfigureAwait(false))
|
||||
{
|
||||
updates.Add(update);
|
||||
if (token.EmitEvents is true)
|
||||
{
|
||||
await context.AddEventAsync(new AgentRunUpdateEvent(this.Id, update)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
ResetUserToAssistantForChangedRoles(roleChanged);
|
||||
|
||||
if (!includeInputInOutput)
|
||||
{
|
||||
messages.Clear();
|
||||
}
|
||||
|
||||
messages.AddRange(updates.ToAgentRunResponse().Messages);
|
||||
|
||||
await context.SendMessageAsync(messages).ConfigureAwait(false);
|
||||
await context.SendMessageAsync(token).ConfigureAwait(false);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides an executor that batches received chat messages that it then publishes as the final result
|
||||
/// when receiving a <see cref="TurnToken"/>.
|
||||
/// </summary>
|
||||
private sealed class ConvertMessageListToCompletedEventExecutor : Executor
|
||||
{
|
||||
private readonly List<ChatMessage> _pendingMessages = [];
|
||||
|
||||
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
|
||||
routeBuilder
|
||||
.AddHandler<ChatMessage>((message, context) => this._pendingMessages.Add(message))
|
||||
.AddHandler<List<ChatMessage>>((messages, _) => this._pendingMessages.AddRange(messages))
|
||||
.AddHandler<TurnToken>(async (token, context) =>
|
||||
{
|
||||
var messages = new List<ChatMessage>(this._pendingMessages);
|
||||
this._pendingMessages.Clear();
|
||||
await context.AddEventAsync(new WorkflowCompletedEvent(messages)).ConfigureAwait(false);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Executor that forwards all messages.</summary>
|
||||
private sealed class ForwardingExecutor : Executor
|
||||
{
|
||||
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
|
||||
@@ -154,7 +216,7 @@ public static class AgentWorkflowBuilder
|
||||
/// Provides an executor that batches received chat messages that it then releases when
|
||||
/// receiving a <see cref="TurnToken"/>.
|
||||
/// </summary>
|
||||
private sealed class ChatMessageBatchingExecutor : Executor
|
||||
private sealed class BatchChatMessagesToListExecutor : Executor
|
||||
{
|
||||
private readonly List<ChatMessage> _pendingMessages = [];
|
||||
|
||||
@@ -195,26 +257,36 @@ public static class AgentWorkflowBuilder
|
||||
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
|
||||
routeBuilder.AddHandler<List<ChatMessage>>(async (messages, context) =>
|
||||
{
|
||||
this._allResults.Add(messages);
|
||||
if (--this._remaining == 0)
|
||||
// TODO: https://github.com/microsoft/agent-framework/issues/784
|
||||
// This locking should not be necessary.
|
||||
bool done;
|
||||
lock (this._allResults)
|
||||
{
|
||||
this._allResults.Add(messages);
|
||||
done = --this._remaining == 0;
|
||||
}
|
||||
|
||||
if (done)
|
||||
{
|
||||
this._remaining = this._expectedInputs;
|
||||
|
||||
var results = this._allResults;
|
||||
this._allResults = new List<List<ChatMessage>>(this._expectedInputs);
|
||||
|
||||
await context.AddEventAsync(new WorkflowCompletedEvent(this._aggregator(results))).ConfigureAwait(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines the orchestration handoff relationships for all agents in the system.
|
||||
/// Provides a builder for specifying the handoff relationships between agents and building the resulting workflow.
|
||||
/// </summary>
|
||||
public sealed class HandoffsWorkflowBuilder
|
||||
{
|
||||
private const string FunctionPrefix = "handoff_to_";
|
||||
private readonly AIAgent _initialAgent;
|
||||
private readonly Dictionary<AIAgent, HashSet<HandoffTarget>> _targets = [];
|
||||
private readonly Dictionary<string, AIAgent> _allAgents = [];
|
||||
private readonly HashSet<AIAgent> _allAgents = new(AIAgentIDEqualityComparer.Instance);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HandoffsWorkflowBuilder"/> class with no handoff relationships.
|
||||
@@ -223,11 +295,11 @@ public static class AgentWorkflowBuilder
|
||||
internal HandoffsWorkflowBuilder(AIAgent initialAgent)
|
||||
{
|
||||
this._initialAgent = initialAgent;
|
||||
this._allAgents.Add(initialAgent.Id, initialAgent);
|
||||
this._allAgents.Add(initialAgent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets additional instructions to provide to an agent about how to perform handoffs.
|
||||
/// Gets or sets additional instructions to provide to an agent that has handoffs about how and when to perform them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// By default, simple instructions are included. This may be set to <see langword="null"/> to avoid including
|
||||
@@ -235,9 +307,10 @@ public static class AgentWorkflowBuilder
|
||||
/// </remarks>
|
||||
public string? HandoffInstructions { get; set; } =
|
||||
$"""
|
||||
You are part of a multi-agent system. Each agent encompasses instructions and tools and can hand off a conversation to another agent
|
||||
when appropriate. Handoffs are achieved by calling a handoff function, generally named `{FunctionPrefix}<agent_id>`. Handoffs
|
||||
between agents are handled seamlessly in the background; do not mention or draw attention to these handoffs in your conversation with the user.
|
||||
You are one agent in a multi-agent system. You can hand off the conversation to another agent if appropriate. Handoffs are achieved
|
||||
by calling a handoff function, named in the form `{FunctionPrefix}<agent_id>`; the description of the function provides details on the
|
||||
target agent of that handoff. Handoffs between agents are handled seamlessly in the background; never mention or narrate these handoffs
|
||||
in your conversation with the user.
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
@@ -246,8 +319,8 @@ public static class AgentWorkflowBuilder
|
||||
/// <param name="from">The source agent.</param>
|
||||
/// <param name="to">The target agents to add as handoff targets for the source agent.</param>
|
||||
/// <returns>The updated <see cref="HandoffsWorkflowBuilder"/> instance.</returns>
|
||||
/// <remarks>The handoff reason for each target is derived from its description or name.</remarks>
|
||||
public HandoffsWorkflowBuilder WithHandoff(AIAgent from, IEnumerable<AIAgent> to)
|
||||
/// <remarks>The handoff reason for each target in <paramref name="to"/> is derived from that agent's description or name.</remarks>
|
||||
public HandoffsWorkflowBuilder WithHandoffs(AIAgent from, IEnumerable<AIAgent> to)
|
||||
{
|
||||
Throw.IfNull(from);
|
||||
Throw.IfNull(to);
|
||||
@@ -265,32 +338,51 @@ public static class AgentWorkflowBuilder
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds handoff relationships from one or more sources agent to a target agent.
|
||||
/// </summary>
|
||||
/// <param name="from">The source agents.</param>
|
||||
/// <param name="to">The target agent to add as a handoff target for each source agent.</param>
|
||||
/// <param name="handoffReason">
|
||||
/// The reason the <paramref name="from"/> should hand off to the <paramref name="to"/>.
|
||||
/// If <see langword="null"/>, the reason is derived from <paramref name="to"/>'s description or name.
|
||||
/// </param>
|
||||
/// <returns>The updated <see cref="HandoffsWorkflowBuilder"/> instance.</returns>
|
||||
public HandoffsWorkflowBuilder WithHandoffs(IEnumerable<AIAgent> from, AIAgent to, string? handoffReason = null)
|
||||
{
|
||||
Throw.IfNull(from);
|
||||
Throw.IfNull(to);
|
||||
|
||||
foreach (var source in from)
|
||||
{
|
||||
if (source is null)
|
||||
{
|
||||
Throw.ArgumentNullException(nameof(from), "One or more source agents are null.");
|
||||
}
|
||||
|
||||
this.WithHandoff(source, to, handoffReason);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a handoff relationship from a source agent to a target agent with a custom handoff reason.
|
||||
/// </summary>
|
||||
/// <param name="from">The source agent.</param>
|
||||
/// <param name="to">The target agent.</param>
|
||||
/// <param name="handoffReason">The reason the <paramref name="from"/> should hand off to the <paramref name="to"/>.</param>
|
||||
/// <param name="handoffReason">
|
||||
/// The reason the <paramref name="from"/> should hand off to the <paramref name="to"/>.
|
||||
/// If <see langword="null"/>, the reason is derived from <paramref name="to"/>'s description or name.
|
||||
/// </param>
|
||||
/// <returns>The updated <see cref="HandoffsWorkflowBuilder"/> instance.</returns>
|
||||
public HandoffsWorkflowBuilder WithHandoff(AIAgent from, AIAgent to, string? handoffReason = null)
|
||||
{
|
||||
Throw.IfNull(from);
|
||||
Throw.IfNull(to);
|
||||
|
||||
#if NET
|
||||
this._allAgents.TryAdd(from.Id, from);
|
||||
this._allAgents.TryAdd(to.Id, to);
|
||||
#else
|
||||
if (!this._allAgents.ContainsKey(from.Id))
|
||||
{
|
||||
this._allAgents.Add(from.Id, from);
|
||||
}
|
||||
|
||||
if (!this._allAgents.ContainsKey(to.Id))
|
||||
{
|
||||
this._allAgents.Add(to.Id, to);
|
||||
}
|
||||
#endif
|
||||
this._allAgents.Add(from);
|
||||
this._allAgents.Add(to);
|
||||
|
||||
if (!this._targets.TryGetValue(from, out var handoffs))
|
||||
{
|
||||
@@ -324,12 +416,12 @@ public static class AgentWorkflowBuilder
|
||||
/// <returns>The workflow built based on the handoffs in the builder.</returns>
|
||||
public Workflow<List<ChatMessage>> Build()
|
||||
{
|
||||
StartHandoffs start = new();
|
||||
EndExecutor end = new();
|
||||
StartHandoffsExecutor start = new();
|
||||
EndHandoffsExecutor end = new();
|
||||
WorkflowBuilder builder = new(start);
|
||||
|
||||
// Create an AgentExecutor for each again.
|
||||
Dictionary<string, AgentExecutor> executors = this._allAgents.ToDictionary(a => a.Key, a => new AgentExecutor(a.Value, this.HandoffInstructions));
|
||||
Dictionary<string, HandoffAgentExecutor> executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, this.HandoffInstructions));
|
||||
|
||||
// Connect the start executor to the initial agent.
|
||||
builder.AddEdge(start, executors[this._initialAgent.Id]);
|
||||
@@ -337,8 +429,8 @@ public static class AgentWorkflowBuilder
|
||||
// Initialize each executor with its handoff targets to the other executors.
|
||||
foreach (var agent in this._allAgents)
|
||||
{
|
||||
executors[agent.Key].Initialize(builder, end, executors,
|
||||
this._targets.TryGetValue(agent.Value, out HashSet<HandoffTarget>? targets) ? targets : []);
|
||||
executors[agent.Id].Initialize(builder, end, executors,
|
||||
this._targets.TryGetValue(agent, out HashSet<HandoffTarget>? targets) ? targets : []);
|
||||
}
|
||||
|
||||
// Build the workflow.
|
||||
@@ -353,7 +445,7 @@ public static class AgentWorkflowBuilder
|
||||
}
|
||||
|
||||
/// <summary>Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token.</summary>
|
||||
private sealed class StartHandoffs : Executor
|
||||
private sealed class StartHandoffsExecutor : Executor
|
||||
{
|
||||
private readonly List<ChatMessage> _pendingMessages = [];
|
||||
|
||||
@@ -373,7 +465,7 @@ public static class AgentWorkflowBuilder
|
||||
}
|
||||
|
||||
/// <summary>Executor used at the end of a handoff workflow to raise a final completed event.</summary>
|
||||
private sealed class EndExecutor : Executor
|
||||
private sealed class EndHandoffsExecutor : Executor
|
||||
{
|
||||
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
|
||||
routeBuilder.AddHandler<HandoffState>((handoff, context) =>
|
||||
@@ -381,45 +473,47 @@ public static class AgentWorkflowBuilder
|
||||
}
|
||||
|
||||
/// <summary>Executor used to represent an agent in a handoffs workflow, responding to <see cref="HandoffState"/> events.</summary>
|
||||
private sealed class AgentExecutor(
|
||||
private sealed class HandoffAgentExecutor(
|
||||
AIAgent agent,
|
||||
string? instructions) : Executor($"{agent.DisplayName}/{CreateId()}")
|
||||
string? handoffInstructions) : Executor(GetDescriptiveIdFromAgent(agent))
|
||||
{
|
||||
private static readonly JsonElement s_handoffSchema = AIFunctionFactory.Create(
|
||||
([Description("The reason for the handoff")] string? reasonForHandoff) => { }).JsonSchema;
|
||||
private static readonly AIFunctionDeclaration s_endFunction = AIFunctionFactory.CreateDeclaration(
|
||||
name: $"end_{CreateId()}",
|
||||
description: "Invoke this function when all work is completed and no further interactions are required.",
|
||||
jsonSchema: AIFunctionFactory.Create(() => { }).JsonSchema);
|
||||
|
||||
private readonly AIAgent _agent = agent;
|
||||
private readonly HashSet<string> _handoffFunctionNames = [];
|
||||
private readonly ChatClientAgentRunOptions _agentOptions = new()
|
||||
{
|
||||
ChatOptions = new()
|
||||
{
|
||||
Instructions = instructions,
|
||||
Tools = [s_endFunction],
|
||||
}
|
||||
};
|
||||
private ChatClientAgentRunOptions? _agentOptions;
|
||||
|
||||
public void Initialize(
|
||||
WorkflowBuilder builder,
|
||||
Executor end,
|
||||
Dictionary<string, AgentExecutor> executors,
|
||||
IEnumerable<HandoffTarget> handoffs) =>
|
||||
Dictionary<string, HandoffAgentExecutor> executors,
|
||||
HashSet<HandoffTarget> handoffs) =>
|
||||
builder.AddSwitch(this, sb =>
|
||||
{
|
||||
foreach (HandoffTarget handoff in handoffs)
|
||||
if (handoffs.Count != 0)
|
||||
{
|
||||
var handoffFunc = AIFunctionFactory.CreateDeclaration($"{FunctionPrefix}{CreateId()}", handoff.Reason, s_handoffSchema);
|
||||
Debug.Assert(this._agentOptions is null);
|
||||
this._agentOptions = new()
|
||||
{
|
||||
ChatOptions = new()
|
||||
{
|
||||
AllowMultipleToolCalls = false,
|
||||
Instructions = handoffInstructions,
|
||||
Tools = [],
|
||||
},
|
||||
};
|
||||
|
||||
this._handoffFunctionNames.Add(handoffFunc.Name);
|
||||
foreach (HandoffTarget handoff in handoffs)
|
||||
{
|
||||
var handoffFunc = AIFunctionFactory.CreateDeclaration($"{FunctionPrefix}{GetDescriptiveIdFromAgent(handoff.Target)}", handoff.Reason, s_handoffSchema);
|
||||
|
||||
this._agentOptions.ChatOptions!.Tools!.Add(handoffFunc);
|
||||
this._agentOptions.ChatOptions.AllowMultipleToolCalls = false;
|
||||
this._handoffFunctionNames.Add(handoffFunc.Name);
|
||||
|
||||
sb.AddCase<HandoffState>(state => state?.InvokedHandoff == handoffFunc.Name, executors[handoff.Target.Id]);
|
||||
this._agentOptions.ChatOptions.Tools.Add(handoffFunc);
|
||||
|
||||
sb.AddCase<HandoffState>(state => state?.InvokedHandoff == handoffFunc.Name, executors[handoff.Target.Id]);
|
||||
}
|
||||
}
|
||||
|
||||
sb.WithDefault(end);
|
||||
@@ -432,43 +526,34 @@ public static class AgentWorkflowBuilder
|
||||
List<AgentRunResponseUpdate> updates = [];
|
||||
List<ChatMessage> allMessages = handoffState.Messages;
|
||||
|
||||
while (requestedHandoff is null)
|
||||
List<ChatMessage>? roleChanges = ChangeAssistantToUserForOtherParticipants(this._agent.DisplayName, allMessages);
|
||||
|
||||
await foreach (var update in this._agent.RunStreamingAsync(allMessages, options: this._agentOptions).ConfigureAwait(false))
|
||||
{
|
||||
updates.Clear();
|
||||
await foreach (var update in this._agent.RunStreamingAsync(allMessages, options: this._agentOptions).ConfigureAwait(false))
|
||||
await AddUpdateAsync(update).ConfigureAwait(false);
|
||||
|
||||
foreach (var c in update.Contents)
|
||||
{
|
||||
await AddUpdateAsync(update).ConfigureAwait(false);
|
||||
for (int i = 0; i < update.Contents.Count; i++)
|
||||
if (c is FunctionCallContent fcc && this._handoffFunctionNames.Contains(fcc.Name))
|
||||
{
|
||||
var c = update.Contents[i];
|
||||
if (c is FunctionCallContent fcc)
|
||||
requestedHandoff = fcc.Name;
|
||||
await AddUpdateAsync(new AgentRunResponseUpdate
|
||||
{
|
||||
if (this._handoffFunctionNames.Contains(fcc.Name))
|
||||
{
|
||||
requestedHandoff = fcc.Name;
|
||||
await AddUpdateAsync(new AgentRunResponseUpdate
|
||||
{
|
||||
AgentId = this._agent.Id,
|
||||
AuthorName = this._agent.DisplayName,
|
||||
Contents = [new FunctionResultContent(fcc.CallId, "Transferred.")],
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
Role = ChatRole.Tool,
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
else if (fcc.Name == s_endFunction.Name)
|
||||
{
|
||||
requestedHandoff = s_endFunction.Name;
|
||||
update.Contents.RemoveAt(i);
|
||||
i--;
|
||||
}
|
||||
}
|
||||
AgentId = this._agent.Id,
|
||||
AuthorName = this._agent.DisplayName,
|
||||
Contents = [new FunctionResultContent(fcc.CallId, "Transferred.")],
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
Role = ChatRole.Tool,
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
allMessages.AddRange(updates.ToAgentRunResponse().Messages);
|
||||
}
|
||||
|
||||
allMessages.AddRange(updates.ToAgentRunResponse().Messages);
|
||||
|
||||
ResetUserToAssistantForChangedRoles(roleChanges);
|
||||
|
||||
await context.SendMessageAsync(new HandoffState(handoffState.TurnToken, requestedHandoff, allMessages)).ConfigureAwait(false);
|
||||
|
||||
async Task AddUpdateAsync(AgentRunResponseUpdate update)
|
||||
@@ -486,12 +571,297 @@ public static class AgentWorkflowBuilder
|
||||
TurnToken TurnToken,
|
||||
string? InvokedHandoff,
|
||||
List<ChatMessage> Messages);
|
||||
}
|
||||
|
||||
private static string CreateId() =>
|
||||
/// <summary>
|
||||
/// A manager that manages the flow of a group chat.
|
||||
/// </summary>
|
||||
public abstract class GroupChatManager
|
||||
{
|
||||
private int _maximumIterationCount = 40;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GroupChatManager"/> class.
|
||||
/// </summary>
|
||||
protected GroupChatManager() { }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of iterations in the group chat so far.
|
||||
/// </summary>
|
||||
public int IterationCount { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of iterations allowed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each iteration involves a single interaction with a participating agent.
|
||||
/// The default is 40.
|
||||
/// </remarks>
|
||||
public int MaximumIterationCount
|
||||
{
|
||||
get => this._maximumIterationCount;
|
||||
set => this._maximumIterationCount = Throw.IfLessThan(value, 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects the next agent to participate in the group chat based on the provided chat history and team.
|
||||
/// </summary>
|
||||
/// <param name="history">The chat history to consider.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>The next <see cref="AIAgent"/> to speak. This agent must be part of the chat.</returns>
|
||||
protected internal abstract ValueTask<AIAgent> SelectNextAgentAsync(
|
||||
IReadOnlyList<ChatMessage> history,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Filters the chat history before it's passed to the next agent.
|
||||
/// </summary>
|
||||
/// <param name="history">The chat history to filter.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>The filtered chat history.</returns>
|
||||
protected internal virtual ValueTask<IEnumerable<ChatMessage>> UpdateHistoryAsync(
|
||||
IReadOnlyList<ChatMessage> history,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
new(history);
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the group chat should be terminated based on the provided chat history and iteration count.
|
||||
/// </summary>
|
||||
/// <param name="history">The chat history to consider.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>A <see cref="bool"/> indicating whether the chat should be terminated.</returns>
|
||||
protected internal virtual ValueTask<bool> ShouldTerminateAsync(
|
||||
IReadOnlyList<ChatMessage> history,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
new(this.MaximumIterationCount is int max && this.IterationCount >= max);
|
||||
|
||||
/// <summary>
|
||||
/// Resets the state of the manager for a new group chat session.
|
||||
/// </summary>
|
||||
protected internal virtual void Reset()
|
||||
{
|
||||
this.IterationCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides a <see cref="GroupChatManager"/> that selects agents in a round-robin fashion.
|
||||
/// </summary>
|
||||
public class RoundRobinGroupChatManager : GroupChatManager
|
||||
{
|
||||
private readonly IReadOnlyList<AIAgent> _agents;
|
||||
private readonly Func<RoundRobinGroupChatManager, IEnumerable<ChatMessage>, CancellationToken, ValueTask<bool>>? _shouldTerminateFunc;
|
||||
private int _nextIndex;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RoundRobinGroupChatManager"/> class.
|
||||
/// </summary>
|
||||
/// <param name="agents">The agents to be managed as part of this workflow.</param>
|
||||
/// <param name="shouldTerminateFunc">
|
||||
/// An optional function that determines whether the group chat should terminate based on the chat history
|
||||
/// before factoring in the default behavior, which is to terminate based only on the iteration count.
|
||||
/// </param>
|
||||
public RoundRobinGroupChatManager(
|
||||
IReadOnlyList<AIAgent> agents,
|
||||
Func<RoundRobinGroupChatManager, IEnumerable<ChatMessage>, CancellationToken, ValueTask<bool>>? shouldTerminateFunc = null)
|
||||
{
|
||||
Throw.IfNullOrEmpty(agents);
|
||||
foreach (var agent in agents)
|
||||
{
|
||||
Throw.IfNull(agent, nameof(agents));
|
||||
}
|
||||
|
||||
this._agents = agents;
|
||||
this._shouldTerminateFunc = shouldTerminateFunc;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected internal override ValueTask<AIAgent> SelectNextAgentAsync(
|
||||
IReadOnlyList<ChatMessage> history, CancellationToken cancellationToken = default)
|
||||
{
|
||||
AIAgent nextAgent = this._agents[this._nextIndex];
|
||||
|
||||
this._nextIndex = (this._nextIndex + 1) % this._agents.Count;
|
||||
|
||||
return new ValueTask<AIAgent>(nextAgent);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected internal override async ValueTask<bool> ShouldTerminateAsync(
|
||||
IReadOnlyList<ChatMessage> history, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (this._shouldTerminateFunc is { } func && await func(this, history, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return await base.ShouldTerminateAsync(history, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected internal override void Reset()
|
||||
{
|
||||
base.Reset();
|
||||
this._nextIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides a builder for specifying group chat relationships between agents and building the resulting workflow.
|
||||
/// </summary>
|
||||
public sealed class GroupChatWorkflowBuilder
|
||||
{
|
||||
private readonly Func<IReadOnlyList<AIAgent>, GroupChatManager> _managerFactory;
|
||||
private readonly HashSet<AIAgent> _participants = new(AIAgentIDEqualityComparer.Instance);
|
||||
|
||||
internal GroupChatWorkflowBuilder(Func<IReadOnlyList<AIAgent>, GroupChatManager> managerFactory) =>
|
||||
this._managerFactory = managerFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Adds the specified <paramref name="agents"/> as participants to the group chat workflow.
|
||||
/// </summary>
|
||||
/// <param name="agents">The agents to add as participants.</param>
|
||||
/// <returns>This instance of the <see cref="GroupChatWorkflowBuilder"/>.</returns>
|
||||
public GroupChatWorkflowBuilder AddParticipants(params IEnumerable<AIAgent> agents)
|
||||
{
|
||||
Throw.IfNull(agents);
|
||||
|
||||
foreach (var agent in agents)
|
||||
{
|
||||
if (agent is null)
|
||||
{
|
||||
Throw.ArgumentNullException(nameof(agents), "One or more target agents are null.");
|
||||
}
|
||||
|
||||
this._participants.Add(agent);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="Workflow{T}"/> composed of agents that operate via group chat, with the next
|
||||
/// agent to process messages selected by the group chat manager.
|
||||
/// </summary>
|
||||
/// <returns>The workflow built based on the group chat in the builder.</returns>
|
||||
public Workflow<List<ChatMessage>> Build()
|
||||
{
|
||||
AIAgent[] agents = this._participants.ToArray();
|
||||
Dictionary<AIAgent, ExecutorIsh> agentMap = agents.ToDictionary(a => a, a => (ExecutorIsh)new AgentRunStreamingExecutor(a, includeInputInOutput: true));
|
||||
|
||||
GroupChatHost host = new(agents, agentMap, this._managerFactory);
|
||||
|
||||
WorkflowBuilder builder = new(host);
|
||||
|
||||
foreach (var participant in agentMap.Values)
|
||||
{
|
||||
builder
|
||||
.AddEdge(host, participant)
|
||||
.AddEdge(participant, host);
|
||||
}
|
||||
|
||||
return builder.Build<List<ChatMessage>>();
|
||||
}
|
||||
|
||||
private sealed class GroupChatHost(AIAgent[] agents, Dictionary<AIAgent, ExecutorIsh> agentMap, Func<IReadOnlyList<AIAgent>, GroupChatManager> managerFactory) : Executor
|
||||
{
|
||||
private readonly AIAgent[] _agents = agents;
|
||||
private readonly Dictionary<AIAgent, ExecutorIsh> _agentMap = agentMap;
|
||||
private readonly Func<IReadOnlyList<AIAgent>, GroupChatManager> _managerFactory = managerFactory;
|
||||
private readonly List<ChatMessage> _pendingMessages = [];
|
||||
|
||||
private GroupChatManager? _manager;
|
||||
|
||||
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => routeBuilder
|
||||
.AddHandler<string>((message, context) => this._pendingMessages.Add(new(ChatRole.User, message)))
|
||||
.AddHandler<ChatMessage>((message, context) => this._pendingMessages.Add(message))
|
||||
.AddHandler<IEnumerable<ChatMessage>>((messages, _) => this._pendingMessages.AddRange(messages))
|
||||
.AddHandler<ChatMessage[]>((messages, _) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed
|
||||
.AddHandler<List<ChatMessage>>((messages, _) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed
|
||||
.AddHandler<TurnToken>(async (token, context) =>
|
||||
{
|
||||
List<ChatMessage> messages = [.. this._pendingMessages];
|
||||
this._pendingMessages.Clear();
|
||||
|
||||
this._manager ??= this._managerFactory(this._agents);
|
||||
|
||||
if (!await this._manager.ShouldTerminateAsync(messages).ConfigureAwait(false))
|
||||
{
|
||||
var filtered = await this._manager.UpdateHistoryAsync(messages).ConfigureAwait(false);
|
||||
messages = filtered is null || ReferenceEquals(filtered, messages) ? messages : [.. filtered];
|
||||
|
||||
if (await this._manager.SelectNextAgentAsync(messages).ConfigureAwait(false) is AIAgent nextAgent &&
|
||||
this._agentMap.TryGetValue(nextAgent, out var executor))
|
||||
{
|
||||
this._manager.IterationCount++;
|
||||
await context.SendMessageAsync(messages, executor.Id).ConfigureAwait(false);
|
||||
await context.SendMessageAsync(token, executor.Id).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this._manager = null;
|
||||
await context.AddEventAsync(new WorkflowCompletedEvent(messages)).ConfigureAwait(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Iterates through <paramref name="messages"/> looking for <see cref="ChatRole.Assistant"/> messages and swapping
|
||||
/// any that have a different <see cref="ChatMessage.AuthorName"/> from <paramref name="targetAgentName"/> to <see cref="ChatRole.User"/>.
|
||||
/// </summary>
|
||||
private static List<ChatMessage>? ChangeAssistantToUserForOtherParticipants(string targetAgentName, List<ChatMessage> messages)
|
||||
{
|
||||
List<ChatMessage>? roleChanged = null;
|
||||
foreach (var m in messages)
|
||||
{
|
||||
if (m.Role == ChatRole.Assistant &&
|
||||
m.AuthorName != targetAgentName &&
|
||||
m.Contents.All(c => c is TextContent or DataContent or UriContent or UsageContent))
|
||||
{
|
||||
m.Role = ChatRole.User;
|
||||
(roleChanged ??= []).Add(m);
|
||||
}
|
||||
}
|
||||
|
||||
return roleChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Undoes changes made by <see cref="ChangeAssistantToUserForOtherParticipants(string, List{ChatMessage})"/>
|
||||
/// when passed the list of changes made by that method.
|
||||
/// </summary>
|
||||
private static void ResetUserToAssistantForChangedRoles(List<ChatMessage>? roleChanged)
|
||||
{
|
||||
if (roleChanged is not null)
|
||||
{
|
||||
foreach (var m in roleChanged)
|
||||
{
|
||||
m.Role = ChatRole.Assistant;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Derives from an agent a unique but also hopefully descriptive name that can be used as an executor's name or in a function name.</summary>
|
||||
private static string GetDescriptiveIdFromAgent(AIAgent agent)
|
||||
{
|
||||
string id = string.IsNullOrEmpty(agent.Name) ? agent.Id : $"{agent.Name}_{agent.Id}";
|
||||
return InvalidNameCharsRegex().Replace(id, "_");
|
||||
}
|
||||
|
||||
/// <summary>Regex that flags any character other than ASCII digits or letters or the underscore.</summary>
|
||||
#if NET
|
||||
RandomNumberGenerator.GetString("abcdefghijklmnopqrstuvwxyz0123456789", 24);
|
||||
[GeneratedRegex("[^0-9A-Za-z_]+")]
|
||||
private static partial Regex InvalidNameCharsRegex();
|
||||
#else
|
||||
Guid.NewGuid().ToString("N");
|
||||
private static Regex InvalidNameCharsRegex() => s_invalidNameCharsRegex;
|
||||
private static readonly Regex s_invalidNameCharsRegex = new("[^0-9A-Za-z_]+", RegexOptions.Compiled);
|
||||
#endif
|
||||
|
||||
private sealed class AIAgentIDEqualityComparer : IEqualityComparer<AIAgent>
|
||||
{
|
||||
public static AIAgentIDEqualityComparer Instance { get; } = new();
|
||||
public bool Equals(AIAgent? x, AIAgent? y) => x?.Id == y?.Id;
|
||||
public int GetHashCode([DisallowNull] AIAgent obj) => obj?.GetHashCode() ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,9 +57,7 @@ internal sealed class MessageMerger
|
||||
|
||||
public List<ChatMessage> ComputeFlattened()
|
||||
{
|
||||
List<ChatMessage> result = this.UpdatesByMessageId.Keys.Select(AggregateUpdatesToMessage)
|
||||
.ToList();
|
||||
|
||||
List<ChatMessage> result = this.UpdatesByMessageId.Keys.SelectMany(AggregateUpdatesToMessage).ToList();
|
||||
if (this.DanglingUpdates.Count > 0)
|
||||
{
|
||||
result.AddRange(this.ComputeDangling().Messages);
|
||||
@@ -67,7 +65,7 @@ internal sealed class MessageMerger
|
||||
|
||||
return result;
|
||||
|
||||
ChatMessage AggregateUpdatesToMessage(string messageId)
|
||||
IList<ChatMessage> AggregateUpdatesToMessage(string messageId)
|
||||
{
|
||||
List<AgentRunResponseUpdate> updates = this.UpdatesByMessageId[messageId];
|
||||
if (updates.Count == 0)
|
||||
@@ -75,13 +73,19 @@ internal sealed class MessageMerger
|
||||
throw new InvalidOperationException($"No updates found for message ID '{messageId}' in response '{this.ResponseId}'.");
|
||||
}
|
||||
|
||||
return updates.Aggregate(null,
|
||||
(ChatMessage? previous, AgentRunResponseUpdate current) =>
|
||||
return updates.Select(oldUpdate =>
|
||||
oldUpdate.RawRepresentation as ChatResponseUpdate ??
|
||||
new()
|
||||
{
|
||||
return previous is null
|
||||
? current.ToChatMessage()
|
||||
: previous.UpdateWith(current);
|
||||
})!;
|
||||
AdditionalProperties = oldUpdate.AdditionalProperties,
|
||||
AuthorName = oldUpdate.AuthorName,
|
||||
Contents = oldUpdate.Contents,
|
||||
CreatedAt = oldUpdate.CreatedAt,
|
||||
MessageId = oldUpdate.MessageId,
|
||||
RawRepresentation = oldUpdate.RawRepresentation,
|
||||
Role = oldUpdate.Role,
|
||||
ResponseId = oldUpdate.ResponseId,
|
||||
}).ToChatResponse().Messages;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,13 +174,28 @@ internal sealed class MessageMerger
|
||||
}
|
||||
|
||||
messages.AddRange(this._danglingState.ComputeFlattened());
|
||||
|
||||
// Remove any empty text contents or messages that are now empty.
|
||||
foreach (var m in messages)
|
||||
{
|
||||
for (int i = m.Contents.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (m.Contents[i] is TextContent textContent &&
|
||||
string.IsNullOrWhiteSpace(textContent.Text))
|
||||
{
|
||||
m.Contents.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
messages.RemoveAll(m => m.Contents.Count == 0);
|
||||
|
||||
return new AgentRunResponse(messages)
|
||||
{
|
||||
ResponseId = primaryResponseId,
|
||||
AgentId = primaryAgentId
|
||||
?? primaryAgentName
|
||||
?? (agentIds.Count == 1 ? agentIds.First() : null),
|
||||
CreatedAt = DateTimeOffset.Now,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Usage = usage,
|
||||
AdditionalProperties = additionalProperties
|
||||
};
|
||||
|
||||
@@ -118,7 +118,7 @@ public class Run
|
||||
/// <param name="cancellation">A <see cref="CancellationToken"/> that can be used to cancel the workflow execution.</param>
|
||||
/// <param name="responses">An array of <see cref="ExternalResponse"/> objects to send to the workflow.</param>
|
||||
/// <returns><c>true</c> if the workflow had any output events, <c>false</c> otherwise.</returns>
|
||||
public async ValueTask<bool> ResumeAsync(CancellationToken cancellation = default, params ExternalResponse[] responses)
|
||||
public async ValueTask<bool> ResumeAsync(CancellationToken cancellation = default, params IEnumerable<ExternalResponse> responses)
|
||||
{
|
||||
foreach (ExternalResponse response in responses)
|
||||
{
|
||||
@@ -135,9 +135,9 @@ public class Run
|
||||
/// <param name="messages">An array of messages to send to the workflow. Messages will only be sent if they are valid
|
||||
/// input types to the starting executor or a <see cref="ExternalResponse"/>.</param>
|
||||
/// <returns><c>true</c> if the workflow had any output events, <c>false</c> otherwise.</returns>
|
||||
public async ValueTask<bool> ResumeAsync<T>(CancellationToken cancellation = default, params T[] messages)
|
||||
public async ValueTask<bool> ResumeAsync<T>(CancellationToken cancellation = default, params IEnumerable<T> messages)
|
||||
{
|
||||
if (messages is ExternalResponse[] responses)
|
||||
if (messages is IEnumerable<ExternalResponse> responses)
|
||||
{
|
||||
return await this.ResumeAsync(cancellation, responses).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ internal sealed class AIAgentHostExecutor : Executor
|
||||
|
||||
async ValueTask PublishCurrentMessageAsync()
|
||||
{
|
||||
if (currentStreamingMessage is not null)
|
||||
if (currentStreamingMessage is not null && updates.Count > 0)
|
||||
{
|
||||
currentStreamingMessage.Contents = updates;
|
||||
updates = [];
|
||||
|
||||
@@ -30,7 +30,7 @@ public sealed class SwitchBuilder
|
||||
/// <param name="executors">One or more executors to associate with the predicate. Each executor will be invoked if the predicate matches.
|
||||
/// Cannot be null.</param>
|
||||
/// <returns>The current <see cref="SwitchBuilder"/> instance, allowing for method chaining.</returns>
|
||||
public SwitchBuilder AddCase<T>(Func<T?, bool> predicate, params ExecutorIsh[] executors)
|
||||
public SwitchBuilder AddCase<T>(Func<T?, bool> predicate, params IEnumerable<ExecutorIsh> executors)
|
||||
{
|
||||
Throw.IfNull(predicate);
|
||||
Throw.IfNull(executors);
|
||||
@@ -60,7 +60,7 @@ public sealed class SwitchBuilder
|
||||
/// </summary>
|
||||
/// <param name="executors"></param>
|
||||
/// <returns></returns>
|
||||
public SwitchBuilder WithDefault(params ExecutorIsh[] executors)
|
||||
public SwitchBuilder WithDefault(params IEnumerable<ExecutorIsh> executors)
|
||||
{
|
||||
Throw.IfNull(executors);
|
||||
|
||||
|
||||
@@ -211,7 +211,7 @@ public class WorkflowBuilder
|
||||
/// <param name="source">The source executor from which the fan-out edge originates. Cannot be null.</param>
|
||||
/// <param name="targets">One or more target executors that will receive the fan-out edge. Cannot be null or empty.</param>
|
||||
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
|
||||
public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, params ExecutorIsh[] targets)
|
||||
public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, params IEnumerable<ExecutorIsh> targets)
|
||||
=> this.AddFanOutEdge<object>(source, null, targets);
|
||||
|
||||
internal static Func<object?, int, IEnumerable<int>>? CreateEdgeAssignerFunc<T>(Func<T?, int, IEnumerable<int>>? partitioner)
|
||||
@@ -243,16 +243,24 @@ public class WorkflowBuilder
|
||||
/// If null, messages will route to all targets.</param>
|
||||
/// <param name="targets">One or more target executors that will receive the fan-out edge. Cannot be null or empty.</param>
|
||||
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
|
||||
public WorkflowBuilder AddFanOutEdge<T>(ExecutorIsh source, Func<T?, int, IEnumerable<int>>? partitioner = null, params ExecutorIsh[] targets)
|
||||
public WorkflowBuilder AddFanOutEdge<T>(ExecutorIsh source, Func<T?, int, IEnumerable<int>>? partitioner = null, params IEnumerable<ExecutorIsh> targets)
|
||||
{
|
||||
Throw.IfNull(source);
|
||||
Throw.IfNullOrEmpty(targets);
|
||||
Throw.IfNull(targets);
|
||||
|
||||
List<string> sinkIds = targets.Select(target =>
|
||||
{
|
||||
Throw.IfNull(target, nameof(targets));
|
||||
return this.Track(target).Id;
|
||||
}).ToList();
|
||||
|
||||
Throw.IfNullOrEmpty(sinkIds, nameof(targets));
|
||||
|
||||
FanOutEdgeData fanOutEdge = new(
|
||||
this.Track(source).Id,
|
||||
targets.Select(target => this.Track(target).Id).ToList(),
|
||||
this.TakeEdgeId(),
|
||||
CreateEdgeAssignerFunc(partitioner));
|
||||
this.Track(source).Id,
|
||||
sinkIds,
|
||||
this.TakeEdgeId(),
|
||||
CreateEdgeAssignerFunc(partitioner));
|
||||
|
||||
this.EnsureEdgesFor(source.Id).Add(new(fanOutEdge));
|
||||
|
||||
@@ -269,15 +277,23 @@ public class WorkflowBuilder
|
||||
/// <param name="target">The target executor that receives input from the specified source executors. Cannot be null.</param>
|
||||
/// <param name="sources">One or more source executors that provide input to the target. Cannot be null or empty.</param>
|
||||
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
|
||||
public WorkflowBuilder AddFanInEdge(ExecutorIsh target, params ExecutorIsh[] sources)
|
||||
public WorkflowBuilder AddFanInEdge(ExecutorIsh target, params IEnumerable<ExecutorIsh> sources)
|
||||
{
|
||||
Throw.IfNull(target);
|
||||
Throw.IfNullOrEmpty(sources);
|
||||
Throw.IfNull(sources);
|
||||
|
||||
List<string> sourceIds = sources.Select(source =>
|
||||
{
|
||||
Throw.IfNull(source, nameof(sources));
|
||||
return this.Track(source).Id;
|
||||
}).ToList();
|
||||
|
||||
Throw.IfNullOrEmpty(sourceIds, nameof(sources));
|
||||
|
||||
FanInEdgeData edgeData = new(
|
||||
sources.Select(source => this.Track(source).Id).ToList(),
|
||||
this.Track(target).Id,
|
||||
this.TakeEdgeId());
|
||||
sourceIds,
|
||||
this.Track(target).Id,
|
||||
this.TakeEdgeId());
|
||||
|
||||
foreach (string sourceId in edgeData.SourceIds)
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Agents.Workflows.Specialized;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
@@ -24,15 +25,19 @@ public static class WorkflowBuilderExtensions
|
||||
/// <param name="source">The source executor from which messages will be forwarded.</param>
|
||||
/// <param name="executors">The target executors to which messages will be forwarded.</param>
|
||||
/// <returns>The updated <see cref="WorkflowBuilder"/> instance.</returns>
|
||||
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorIsh source, params ExecutorIsh[] executors)
|
||||
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorIsh source, params IEnumerable<ExecutorIsh> executors)
|
||||
{
|
||||
Throw.IfNullOrEmpty(executors);
|
||||
Throw.IfNull(executors);
|
||||
|
||||
Func<object?, bool> predicate = WorkflowBuilder.CreateConditionFunc<TMessage>((Func<object?, bool>)IsAllowedType)!;
|
||||
|
||||
if (executors.Length == 1)
|
||||
#if NET
|
||||
if (executors.TryGetNonEnumeratedCount(out int count) && count == 1)
|
||||
#else
|
||||
if (executors is ICollection<ExecutorIsh> { Count: 1 })
|
||||
#endif
|
||||
{
|
||||
return builder.AddEdge(source, executors[0], predicate);
|
||||
return builder.AddEdge(source, executors.First(), predicate);
|
||||
}
|
||||
|
||||
return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, executors));
|
||||
@@ -50,15 +55,19 @@ public static class WorkflowBuilderExtensions
|
||||
/// <param name="source">The source executor from which messages will be forwarded.</param>
|
||||
/// <param name="executors">The target executors to which messages, except those of type <typeparamref name="TMessage"/>, will be forwarded.</param>
|
||||
/// <returns>The updated <see cref="WorkflowBuilder"/> instance with the added edges.</returns>
|
||||
public static WorkflowBuilder ForwardExcept<TMessage>(this WorkflowBuilder builder, ExecutorIsh source, params ExecutorIsh[] executors)
|
||||
public static WorkflowBuilder ForwardExcept<TMessage>(this WorkflowBuilder builder, ExecutorIsh source, params IEnumerable<ExecutorIsh> executors)
|
||||
{
|
||||
Throw.IfNullOrEmpty(executors);
|
||||
Throw.IfNull(executors);
|
||||
|
||||
Func<object?, bool> predicate = WorkflowBuilder.CreateConditionFunc<TMessage>((Func<object?, bool>)IsAllowedType)!;
|
||||
|
||||
if (executors.Length == 1)
|
||||
#if NET
|
||||
if (executors.TryGetNonEnumeratedCount(out int count) && count == 1)
|
||||
#else
|
||||
if (executors is ICollection<ExecutorIsh> { Count: 1 })
|
||||
#endif
|
||||
{
|
||||
return builder.AddEdge(source, executors[0], predicate);
|
||||
return builder.AddEdge(source, executors.First(), predicate);
|
||||
}
|
||||
|
||||
return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, executors));
|
||||
@@ -79,25 +88,25 @@ public static class WorkflowBuilderExtensions
|
||||
/// <param name="executors">An ordered array of executors to be added to the chain after the source.</param>
|
||||
/// <returns>The original workflow builder instance with the specified executor chain added.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown if there is a cycle in the chain.</exception>
|
||||
public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh source, params ExecutorIsh[] executors)
|
||||
public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh source, params IEnumerable<ExecutorIsh> executors)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
Throw.IfNull(source);
|
||||
|
||||
HashSet<string> seenExecutors = [source.Id];
|
||||
|
||||
for (int i = 0; i < executors.Length; i++)
|
||||
foreach (var executor in executors)
|
||||
{
|
||||
Throw.IfNull(executors[i], nameof(executors) + $"[{i}]");
|
||||
Throw.IfNull(executor, nameof(executors));
|
||||
|
||||
if (seenExecutors.Contains(executors[i].Id))
|
||||
if (seenExecutors.Contains(executor.Id))
|
||||
{
|
||||
throw new ArgumentException($"Executor '{executors[i].Id}' is already in the chain.", nameof(executors));
|
||||
throw new ArgumentException($"Executor '{executor.Id}' is already in the chain.", nameof(executors));
|
||||
}
|
||||
seenExecutors.Add(executors[i].Id);
|
||||
seenExecutors.Add(executor.Id);
|
||||
|
||||
builder.AddEdge(source, executors[i]);
|
||||
source = executors[i];
|
||||
builder.AddEdge(source, executor);
|
||||
source = executor;
|
||||
}
|
||||
|
||||
return builder;
|
||||
|
||||
@@ -44,7 +44,7 @@ internal sealed class WorkflowMessageStore : ChatMessageStore
|
||||
public IList<ChatMessage> Messages { get; set; } = [];
|
||||
}
|
||||
|
||||
internal void AddMessages(params ChatMessage[] messages) => this._chatMessages.AddRange(messages);
|
||||
internal void AddMessages(params IEnumerable<ChatMessage> messages) => this._chatMessages.AddRange(messages);
|
||||
|
||||
public override Task AddMessagesAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -36,7 +36,7 @@ internal sealed class WorkflowThread : AgentThread
|
||||
|
||||
AgentRunResponseUpdate update = new(ChatRole.Assistant, parts)
|
||||
{
|
||||
CreatedAt = DateTimeOffset.Now,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
};
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ internal sealed class A2AAgent : AIAgent
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override sealed AgentThread GetNewThread()
|
||||
public sealed override AgentThread GetNewThread()
|
||||
=> new A2AAgentThread();
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -25,7 +25,7 @@ internal static class ChatMessageExtensions
|
||||
|
||||
return new Message
|
||||
{
|
||||
MessageId = Guid.NewGuid().ToString(),
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
Role = MessageRole.User,
|
||||
Parts = allParts,
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ public abstract class AIAgent
|
||||
/// <value>
|
||||
/// The identifier of the agent. The default is a random GUID value, but for service agents, it will match the id of the agent in the service.
|
||||
/// </value>
|
||||
public virtual string Id { get; } = Guid.NewGuid().ToString();
|
||||
public virtual string Id { get; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the agent (optional).
|
||||
|
||||
@@ -19,7 +19,7 @@ public abstract class InMemoryAgentThread : AgentThread
|
||||
/// <param name="messageStore">An optional <see cref="InMemoryChatMessageStore"/> to use for storing chat messages. If null, a new instance will be created.</param>
|
||||
protected InMemoryAgentThread(InMemoryChatMessageStore? messageStore = null)
|
||||
{
|
||||
this.MessageStore = messageStore ?? new InMemoryChatMessageStore();
|
||||
this.MessageStore = messageStore ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -28,11 +28,7 @@ public abstract class InMemoryAgentThread : AgentThread
|
||||
/// <param name="messages">The messages to initialize the thread with.</param>
|
||||
protected InMemoryAgentThread(IEnumerable<ChatMessage> messages)
|
||||
{
|
||||
this.MessageStore = new InMemoryChatMessageStore();
|
||||
foreach (var message in messages)
|
||||
{
|
||||
this.MessageStore.Add(message);
|
||||
}
|
||||
this.MessageStore = [.. messages];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -53,8 +49,7 @@ public abstract class InMemoryAgentThread : AgentThread
|
||||
throw new ArgumentException("The serialized thread state must be a JSON object.", nameof(serializedThreadState));
|
||||
}
|
||||
|
||||
var state = JsonSerializer.Deserialize(
|
||||
serializedThreadState,
|
||||
var state = serializedThreadState.Deserialize(
|
||||
AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(InMemoryAgentThreadState))) as InMemoryAgentThreadState;
|
||||
|
||||
this.MessageStore =
|
||||
|
||||
@@ -45,8 +45,7 @@ public abstract class ServiceIdAgentThread : AgentThread
|
||||
throw new ArgumentException("The serialized thread state must be a JSON object.", nameof(serializedThreadState));
|
||||
}
|
||||
|
||||
var state = JsonSerializer.Deserialize(
|
||||
serializedThreadState,
|
||||
var state = serializedThreadState.Deserialize(
|
||||
AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ServiceIdAgentThreadState))) as ServiceIdAgentThreadState;
|
||||
|
||||
if (state?.ServiceThreadId is string serviceThreadId)
|
||||
|
||||
@@ -41,7 +41,7 @@ public class CopilotStudioAgent : AIAgent
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override sealed AgentThread GetNewThread()
|
||||
public sealed override AgentThread GetNewThread()
|
||||
=> new CopilotStudioAgentThread();
|
||||
|
||||
/// <summary>
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ internal static class ActorEntitiesConverter
|
||||
|
||||
return new Message
|
||||
{
|
||||
MessageId = response.MessageId ?? Guid.NewGuid().ToString(),
|
||||
MessageId = response.MessageId ?? Guid.NewGuid().ToString("N"),
|
||||
ContextId = contextId,
|
||||
Role = MessageRole.Agent,
|
||||
Parts = parts
|
||||
|
||||
+1
-1
@@ -177,7 +177,7 @@ internal static class MessageConverter
|
||||
|
||||
var message = new Message
|
||||
{
|
||||
MessageId = chatMessage.MessageId ?? Guid.NewGuid().ToString(),
|
||||
MessageId = chatMessage.MessageId ?? Guid.NewGuid().ToString("N"),
|
||||
Role = ConvertChatRoleToMessageRole(chatMessage.Role),
|
||||
Parts = []
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ internal sealed class A2AAgentWrapper
|
||||
|
||||
public async Task<Message> ProcessMessageAsync(MessageSendParams messageSendParams, CancellationToken cancellationToken)
|
||||
{
|
||||
var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString();
|
||||
var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N");
|
||||
var messageId = messageSendParams.Message.MessageId;
|
||||
|
||||
var actorId = new ActorId(type: this.GetActorType(), key: contextId!);
|
||||
|
||||
@@ -135,7 +135,7 @@ public sealed class AgentProxy : AIAgent
|
||||
Messages = newMessages
|
||||
};
|
||||
|
||||
string messageId = newMessages.LastOrDefault()?.MessageId ?? Guid.NewGuid().ToString();
|
||||
string messageId = newMessages.LastOrDefault()?.MessageId ?? Guid.NewGuid().ToString("N");
|
||||
ActorRequest actorRequest = new(
|
||||
actorId: new ActorId(this.Name, threadId),
|
||||
messageId,
|
||||
|
||||
@@ -137,6 +137,8 @@ public sealed class ChatClientAgent : AIAgent
|
||||
foreach (ChatMessage chatResponseMessage in chatResponse.Messages)
|
||||
{
|
||||
chatResponseMessage.AuthorName ??= agentName;
|
||||
chatResponseMessage.MessageId ??= Guid.NewGuid().ToString("N");
|
||||
chatResponseMessage.CreatedAt ??= DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
// Only notify the thread of new messages if the chatResponse was successful to avoid inconsistent message state in the thread.
|
||||
@@ -194,13 +196,17 @@ public sealed class ChatClientAgent : AIAgent
|
||||
throw;
|
||||
}
|
||||
|
||||
string? messageId = null;
|
||||
while (hasUpdates)
|
||||
{
|
||||
var update = responseUpdatesEnumerator.Current;
|
||||
if (update is not null)
|
||||
{
|
||||
responseUpdates.Add(update);
|
||||
update.AuthorName ??= this.Name;
|
||||
update.CreatedAt ??= DateTimeOffset.UtcNow;
|
||||
update.MessageId ??= (messageId ??= Guid.NewGuid().ToString("N"));
|
||||
|
||||
responseUpdates.Add(update);
|
||||
yield return new(update) { AgentId = this.Id };
|
||||
}
|
||||
|
||||
|
||||
@@ -43,8 +43,7 @@ public class ChatClientAgentThread : AgentThread
|
||||
throw new ArgumentException("The serialized thread state must be a JSON object.", nameof(serializedThreadState));
|
||||
}
|
||||
|
||||
var state = JsonSerializer.Deserialize(
|
||||
serializedThreadState,
|
||||
var state = serializedThreadState.Deserialize(
|
||||
AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ThreadState))) as ThreadState;
|
||||
|
||||
this.AIContextProvider = aiContextProviderFactory?.Invoke(state?.AIContextProviderState ?? default, jsonSerializerOptions);
|
||||
@@ -57,12 +56,9 @@ public class ChatClientAgentThread : AgentThread
|
||||
return;
|
||||
}
|
||||
|
||||
this._messageStore = chatMessageStoreFactory?.Invoke(state?.StoreState ?? default, jsonSerializerOptions);
|
||||
if (this._messageStore is null)
|
||||
{
|
||||
// If we didn't get a custom store, create an in-memory one.
|
||||
this._messageStore = new InMemoryChatMessageStore(state?.StoreState ?? default, jsonSerializerOptions);
|
||||
}
|
||||
this._messageStore =
|
||||
chatMessageStoreFactory?.Invoke(state?.StoreState ?? default, jsonSerializerOptions) ??
|
||||
new InMemoryChatMessageStore(state?.StoreState ?? default, jsonSerializerOptions); // default to an in-memory store
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user