.NET: [BREAKING] Enable sharing of workflow instances across concurrently executing runs (#1464)

* refactor: remove unused internals

* feat: Execution Mode for sharing a workflow among concurrent runs

* feat: Update WorkflowHostAgent to support concurrent execution

* Also update AsAgent APIs to support injecting a CheckpointManager and an IWorkflowExecutionEnvironment

* fix: Make Read logic consistent in DeclarativeWorkflowContext
This commit is contained in:
Jacob Alber
2025-10-15 21:34:17 +00:00
committed by GitHub
parent 69e1ab0409
commit 331c750515
67 changed files with 2152 additions and 1347 deletions
@@ -1,7 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -32,6 +34,9 @@ internal sealed class DeclarativeWorkflowContext : IWorkflowContext
public WorkflowFormulaState State { get; }
public IReadOnlyDictionary<string, string>? TraceContext => this.Source.TraceContext;
/// <inheritdoc/>
public bool ConcurrentRunsEnabled => this.Source.ConcurrentRunsEnabled;
/// <inheritdoc/>
public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default)
=> this.Source.AddEventAsync(workflowEvent, cancellationToken);
@@ -72,18 +77,16 @@ internal sealed class DeclarativeWorkflowContext : IWorkflowContext
this.State.Bind();
}
private bool IsManagedScope(string? scopeName) => scopeName is not null && VariableScopeNames.IsValidName(scopeName);
/// <inheritdoc/>
public async ValueTask<TValue?> ReadStateAsync<TValue>(string key, string? scopeName = null, CancellationToken cancellationToken = default)
{
bool isManagedScope =
scopeName is not null && // null scope cannot be managed
VariableScopeNames.IsValidName(scopeName);
return typeof(TValue) switch
{
// Not a managed scope, just pass through. This is valid when a declarative
// workflow has been ejected to code (where DeclarativeWorkflowContext is also utilized).
_ when !isManagedScope => await this.Source.ReadStateAsync<TValue>(key, scopeName, cancellationToken).ConfigureAwait(false),
_ when !this.IsManagedScope(scopeName) => await this.Source.ReadStateAsync<TValue>(key, scopeName, cancellationToken).ConfigureAwait(false),
// Retrieve formula values directly from the managed state to avoid conversion.
_ when typeof(TValue) == typeof(FormulaValue) => (TValue?)(object?)this.State.Get(key, scopeName),
// Retrieve native types from the source context to avoid conversion.
@@ -91,6 +94,41 @@ internal sealed class DeclarativeWorkflowContext : IWorkflowContext
};
}
public async ValueTask<TValue> ReadOrInitStateAsync<TValue>(string key, Func<TValue> initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default)
{
return typeof(TValue) switch
{
// Not a managed scope, just pass through. This is valid when a declarative
// workflow has been ejected to code (where DeclarativeWorkflowContext is also utilized).
_ when !this.IsManagedScope(scopeName) => await this.Source.ReadOrInitStateAsync(key, initialStateFactory, scopeName, cancellationToken).ConfigureAwait(false),
// Retrieve formula values directly from the managed state to avoid conversion.
_ when typeof(TValue) == typeof(FormulaValue) => await EnsureFormulaValueAsync().ConfigureAwait(false),
// Retrieve native types from the source context to avoid conversion.
_ => await this.Source.ReadOrInitStateAsync(key, initialStateFactory, scopeName, cancellationToken).ConfigureAwait(false),
};
async ValueTask<TValue> EnsureFormulaValueAsync()
{
Debug.Assert(typeof(TValue) == typeof(FormulaValue), "It is a bug to call this method with TValue not === FormulaValue");
FormulaValue? result = this.State.Get(key, scopeName);
if (result is null or BlankValue)
{
result = initialStateFactory() as FormulaValue;
if (result is null)
{
throw new InvalidOperationException($"The initial state factory for key '{key}' in scope '{scopeName}' did not return a FormulaValue.");
}
this.State.Set(key, result, scopeName);
await this.Source.QueueStateUpdateAsync(key, result.AsPortable(), scopeName, cancellationToken)
.ConfigureAwait(false);
}
return (TValue)(object)result!; // The null analyzer is confused here, but it is impossible to hit this line with result is null
}
}
/// <inheritdoc/>
public ValueTask<HashSet<string>> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default)
=> this.Source.ReadStateKeysAsync(scopeName, cancellationToken);
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.RegularExpressions;
namespace Microsoft.Agents.AI.Workflows;
internal static partial class AIAgentExtensions
{
/// <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>
public static string GetDescriptiveId(this 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
[GeneratedRegex("[^0-9A-Za-z]+")]
private static partial Regex InvalidNameCharsRegex();
#else
private static Regex InvalidNameCharsRegex() => s_invalidNameCharsRegex;
private static readonly Regex s_invalidNameCharsRegex = new("[^0-9A-Za-z_]+", RegexOptions.Compiled);
#endif
}
@@ -0,0 +1,13 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
namespace Microsoft.Agents.AI.Workflows;
internal 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;
}
@@ -1,5 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows;
@@ -16,4 +18,41 @@ internal static class AIAgentsAbstractionsExtensions
MessageId = update.MessageId,
RawRepresentation = update.RawRepresentation ?? update,
};
/// <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>
public static List<ChatMessage>? ChangeAssistantToUserForOtherParticipants(this List<ChatMessage> messages, string targetAgentName)
{
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"/> when passed the list of changes
/// made by that method.
/// </summary>
public static void ResetUserToAssistantForChangedRoles(this List<ChatMessage>? roleChanged)
{
if (roleChanged is not null)
{
foreach (var m in roleChanged)
{
m.Role = ChatRole.Assistant;
}
}
}
}
@@ -2,14 +2,9 @@
using System;
using System.Collections.Generic;
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.AI.Workflows.Specialized;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
@@ -130,7 +125,7 @@ public static partial class AgentWorkflowBuilder
// 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)new AgentRunStreamingExecutor(agent, includeInputInOutput: false)).ToArray();
ExecutorIsh[] accumulators = [.. from agent in agentExecutors select (ExecutorIsh)new BatchChatMessagesToListExecutor($"Batcher/{agent.Id}")];
ExecutorIsh[] accumulators = [.. from agent in agentExecutors select (ExecutorIsh)new CollectChatMessagesExecutor($"Batcher/{agent.Id}")];
builder.AddFanOutEdge(start, targets: agentExecutors);
for (int i = 0; i < agentExecutors.Length; i++)
{
@@ -184,763 +179,4 @@ public static partial class AgentWorkflowBuilder
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)), IResettableExecutor
{
private readonly List<ChatMessage> _pendingMessages = [];
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder
.AddHandler<string>((message, _, __) => this._pendingMessages.Add(new(ChatRole.User, message)))
.AddHandler<ChatMessage>((message, _, __) => 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, cancellationToken) =>
{
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, cancellationToken: cancellationToken).ConfigureAwait(false))
{
updates.Add(update);
if (token.EmitEvents is true)
{
await context.AddEventAsync(new AgentRunUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false);
}
}
ResetUserToAssistantForChangedRoles(roleChanged);
if (!includeInputInOutput)
{
messages.Clear();
}
messages.AddRange(updates.ToAgentRunResponse().Messages);
await context.SendMessageAsync(messages, cancellationToken: cancellationToken).ConfigureAwait(false);
await context.SendMessageAsync(token, cancellationToken: cancellationToken).ConfigureAwait(false);
});
public ValueTask ResetAsync()
{
this._pendingMessages.Clear();
return default;
}
}
/// <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 OutputMessagesExecutor() : ChatProtocolExecutor("OutputMessages"), IResettableExecutor
{
protected override ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
=> context.YieldOutputAsync(messages, cancellationToken);
ValueTask IResettableExecutor.ResetAsync() => this.ResetAsync();
}
/// <summary>Executor that forwards all messages.</summary>
private sealed class ChatForwardingExecutor(string id) : Executor(id), IResettableExecutor
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder
.AddHandler<string>((message, context, cancellationToken) => context.SendMessageAsync(new ChatMessage(ChatRole.User, message), cancellationToken: cancellationToken))
.AddHandler<ChatMessage>((message, context, cancellationToken) => context.SendMessageAsync(message, cancellationToken: cancellationToken))
.AddHandler<List<ChatMessage>>((messages, context, cancellationToken) => context.SendMessageAsync(messages, cancellationToken: cancellationToken))
.AddHandler<TurnToken>((turnToken, context, cancellationToken) => context.SendMessageAsync(turnToken, cancellationToken: cancellationToken));
public ValueTask ResetAsync() => default;
}
/// <summary>
/// Provides an executor that batches received chat messages that it then releases when
/// receiving a <see cref="TurnToken"/>.
/// </summary>
private sealed class BatchChatMessagesToListExecutor(string id) : ChatProtocolExecutor(id), IResettableExecutor
{
protected override ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
=> context.SendMessageAsync(messages, cancellationToken: cancellationToken);
ValueTask IResettableExecutor.ResetAsync() => this.ResetAsync();
}
/// <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, IResettableExecutor
{
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) : base("ConcurrentEnd")
{
this._expectedInputs = expectedInputs;
this._aggregator = Throw.IfNull(aggregator);
this._allResults = new List<List<ChatMessage>>(expectedInputs);
this._remaining = expectedInputs;
}
private void Reset()
{
this._allResults = new List<List<ChatMessage>>(this._expectedInputs);
this._remaining = this._expectedInputs;
}
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<List<ChatMessage>>(async (messages, context, cancellationToken) =>
{
// 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.YieldOutputAsync(this._aggregator(results), cancellationToken).ConfigureAwait(false);
}
});
public ValueTask ResetAsync()
{
this.Reset();
return default;
}
}
/// <summary>
/// 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 HashSet<AIAgent> _allAgents = new(AIAgentIDEqualityComparer.Instance);
/// <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);
}
/// <summary>
/// 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
/// any additional instructions, or may be customized to provide more specific guidance.
/// </remarks>
public string? HandoffInstructions { get; set; } =
$"""
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>
/// 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 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);
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 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"/>.
/// 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);
this._allAgents.Add(from);
this._allAgents.Add(to);
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 Build()
{
StartHandoffsExecutor start = new();
EndHandoffsExecutor end = new();
WorkflowBuilder builder = new(start);
// Create an AgentExecutor for each again.
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]);
// Initialize each executor with its handoff targets to the other executors.
foreach (var agent in this._allAgents)
{
executors[agent.Id].Initialize(builder, end, executors,
this._targets.TryGetValue(agent, out HashSet<HandoffTarget>? targets) ? targets : []);
}
// Build the workflow.
return builder.WithOutputFrom(end).Build();
}
/// <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 StartHandoffsExecutor() : Executor("HandoffStart"), IResettableExecutor
{
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, cancellationToken) =>
{
var messages = new List<ChatMessage>(this._pendingMessages);
this._pendingMessages.Clear();
await context.SendMessageAsync(new HandoffState(token, null, messages), cancellationToken: cancellationToken)
.ConfigureAwait(false);
});
public ValueTask ResetAsync()
{
this._pendingMessages.Clear();
return default;
}
}
/// <summary>Executor used at the end of a handoff workflow to raise a final completed event.</summary>
private sealed class EndHandoffsExecutor() : Executor("HandoffEnd"), IResettableExecutor
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<HandoffState>((handoff, context, cancellationToken) =>
context.YieldOutputAsync(handoff.Messages, cancellationToken));
public ValueTask ResetAsync() => default;
}
/// <summary>Executor used to represent an agent in a handoffs workflow, responding to <see cref="HandoffState"/> events.</summary>
private sealed class HandoffAgentExecutor(
AIAgent agent,
string? handoffInstructions) : Executor(GetDescriptiveIdFromAgent(agent)), IResettableExecutor
{
private static readonly JsonElement s_handoffSchema = AIFunctionFactory.Create(
([Description("The reason for the handoff")] string? reasonForHandoff) => { }).JsonSchema;
private readonly AIAgent _agent = agent;
private readonly HashSet<string> _handoffFunctionNames = [];
private ChatClientAgentRunOptions? _agentOptions;
public void Initialize(
WorkflowBuilder builder,
Executor end,
Dictionary<string, HandoffAgentExecutor> executors,
HashSet<HandoffTarget> handoffs) =>
builder.AddSwitch(this, sb =>
{
if (handoffs.Count != 0)
{
Debug.Assert(this._agentOptions is null);
this._agentOptions = new()
{
ChatOptions = new()
{
AllowMultipleToolCalls = false,
Instructions = handoffInstructions,
Tools = [],
},
};
foreach (HandoffTarget handoff in handoffs)
{
var handoffFunc = AIFunctionFactory.CreateDeclaration($"{FunctionPrefix}{GetDescriptiveIdFromAgent(handoff.Target)}", handoff.Reason, s_handoffSchema);
this._handoffFunctionNames.Add(handoffFunc.Name);
this._agentOptions.ChatOptions.Tools.Add(handoffFunc);
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, cancellationToken) =>
{
string? requestedHandoff = null;
List<AgentRunResponseUpdate> updates = [];
List<ChatMessage> allMessages = handoffState.Messages;
List<ChatMessage>? roleChanges = ChangeAssistantToUserForOtherParticipants(this._agent.DisplayName, allMessages);
await foreach (var update in this._agent.RunStreamingAsync(allMessages,
options: this._agentOptions,
cancellationToken: cancellationToken)
.ConfigureAwait(false))
{
await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false);
foreach (var c in update.Contents)
{
if (c is FunctionCallContent fcc && 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,
},
cancellationToken
)
.ConfigureAwait(false);
}
}
}
allMessages.AddRange(updates.ToAgentRunResponse().Messages);
ResetUserToAssistantForChangedRoles(roleChanges);
await context.SendMessageAsync(new HandoffState(handoffState.TurnToken, requestedHandoff, allMessages), cancellationToken: cancellationToken).ConfigureAwait(false);
async Task AddUpdateAsync(AgentRunResponseUpdate update, CancellationToken cancellationToken)
{
updates.Add(update);
if (handoffState.TurnToken.EmitEvents is true)
{
await context.AddEventAsync(new AgentRunUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false);
}
}
});
public ValueTask ResetAsync() => default;
}
private sealed record class HandoffState(
TurnToken TurnToken,
string? InvokedHandoff,
List<ChatMessage> Messages);
}
/// <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">The <see cref="CancellationToken"/> to monitor for cancellation requests.
/// The default is <see cref="CancellationToken.None"/>.</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">The <see cref="CancellationToken"/> to monitor for cancellation requests.
/// The default is <see cref="CancellationToken.None"/>.</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">The <see cref="CancellationToken"/> to monitor for cancellation requests.
/// The default is <see cref="CancellationToken.None"/>.</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"/> 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 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.WithOutputFrom(host).Build();
}
private sealed class GroupChatHost(AIAgent[] agents, Dictionary<AIAgent, ExecutorIsh> agentMap, Func<IReadOnlyList<AIAgent>, GroupChatManager> managerFactory) : Executor("GroupChatHost"), IResettableExecutor
{
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, cancellationToken) =>
{
List<ChatMessage> messages = [.. this._pendingMessages];
this._pendingMessages.Clear();
this._manager ??= this._managerFactory(this._agents);
if (!await this._manager.ShouldTerminateAsync(messages, cancellationToken).ConfigureAwait(false))
{
var filtered = await this._manager.UpdateHistoryAsync(messages, cancellationToken).ConfigureAwait(false);
messages = filtered is null || ReferenceEquals(filtered, messages) ? messages : [.. filtered];
if (await this._manager.SelectNextAgentAsync(messages, cancellationToken).ConfigureAwait(false) is AIAgent nextAgent &&
this._agentMap.TryGetValue(nextAgent, out var executor))
{
this._manager.IterationCount++;
await context.SendMessageAsync(messages, executor.Id, cancellationToken).ConfigureAwait(false);
await context.SendMessageAsync(token, executor.Id, cancellationToken).ConfigureAwait(false);
return;
}
}
this._manager = null;
await context.YieldOutputAsync(messages, cancellationToken).ConfigureAwait(false);
});
public ValueTask ResetAsync()
{
this._pendingMessages.Clear();
this._manager = null;
return default;
}
}
}
/// <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
[GeneratedRegex("[^0-9A-Za-z_]+")]
private static partial Regex InvalidNameCharsRegex();
#else
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;
}
}
@@ -19,34 +19,39 @@ namespace Microsoft.Agents.AI.Workflows;
/// function receives the current aggregate (or null if this is the first message) and the input message, and returns
/// the updated aggregate.</param>
/// <param name="options">Optional configuration settings for the executor. If null, default options are used.</param>
/// <param name="declareCrossRunShareable">Declare that this executor may be used simultaneously by multiple runs safely.</param>
/// <seealso cref="StreamingAggregators"/>
public class AggregatingExecutor<TInput, TAggregate>(string id,
Func<TAggregate?, TInput, TAggregate?> aggregator,
ExecutorOptions? options = null) : Executor<TInput, TAggregate?>(id, options)
ExecutorOptions? options = null,
bool declareCrossRunShareable = false) : Executor<TInput, TAggregate?>(id, options, declareCrossRunShareable)
{
private const string AggregateStateKey = "Aggregate";
private TAggregate? _runningAggregate;
/// <inheritdoc/>
public override ValueTask<TAggregate?> HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default)
public override async ValueTask<TAggregate?> HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken)
{
this._runningAggregate = aggregator(this._runningAggregate, message);
return new(this._runningAggregate);
}
TAggregate? runningAggregate = default;
await context.InvokeWithStateAsync<PortableValue?>(InvokeAggregatorAsync, AggregateStateKey, cancellationToken: cancellationToken)
.ConfigureAwait(false);
/// <inheritdoc/>
protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
await context.QueueStateUpdateAsync(AggregateStateKey, this._runningAggregate, cancellationToken: cancellationToken).ConfigureAwait(false);
return runningAggregate;
await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false);
}
ValueTask<PortableValue?> InvokeAggregatorAsync(PortableValue? maybeState, IWorkflowContext context, CancellationToken cancellationToken)
{
if (maybeState == null || !maybeState.Is(out runningAggregate))
{
runningAggregate = default;
}
/// <inheritdoc/>
protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false);
runningAggregate = aggregator(runningAggregate, message);
this._runningAggregate = await context.ReadStateAsync<TAggregate>(AggregateStateKey, cancellationToken: cancellationToken).ConfigureAwait(false);
if (runningAggregate == null)
{
return new((PortableValue?)null);
}
return new(new PortableValue(runningAggregate));
}
}
}
@@ -1,33 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Execution;
namespace Microsoft.Agents.AI.Workflows;
internal sealed class AsyncBarrier()
{
private readonly InitLocked<TaskCompletionSource<object>> _completionSource = new();
public async ValueTask<bool> JoinAsync(CancellationToken cancellationToken = default)
{
this._completionSource.Init(() => new TaskCompletionSource<object>(TaskCreationOptions.RunContinuationsAsynchronously));
TaskCompletionSource<object> completionSource = this._completionSource.Get()!;
// Create a new completion source to track cancellation, because cancelling a single waiter's join
// should not cancel the entire barrier.
TaskCompletionSource<object> cancellationSource = new();
using CancellationTokenRegistration registration = cancellationToken.Register(() => cancellationSource.SetResult(new()));
await Task.WhenAny(completionSource.Task, cancellationSource.Task).ConfigureAwait(false);
return !cancellationToken.IsCancellationRequested;
}
public bool ReleaseBarrier()
{
// If there is no completion source, then there are no waiters.
return this._completionSource.Get()?.TrySetResult(new()) ?? false;
}
}
@@ -1,42 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Workflows;
internal sealed class AsyncCoordinator
{
private AsyncBarrier? _coordinationBarrier;
/// <summary>
/// Wait for the Coordination owner to mark the next coordination point, then continue execution.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
/// The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>
/// A task that represents the asynchronous operation. The task result is <see langword="true"/>
/// if the wait was completed; otherwise, for example, if the wait was cancelled, <see langword="false"/>.
/// </returns>
public async ValueTask<bool> WaitForCoordinationAsync(CancellationToken cancellationToken = default)
{
// There is a chance that we might get a stale barrier that is getting released if there is a
// release happening concurrently with this call. This is by design, and should be considered
// when using this class.
AsyncBarrier actualBarrier = this._coordinationBarrier
?? Interlocked.CompareExchange(ref this._coordinationBarrier, new(), null)
?? this._coordinationBarrier!; // Re-read after setting
return await actualBarrier.JoinAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Marks the coordination point and releases any waiting operations if a coordination barrier is present.
/// </summary>
/// <returns>true if a coordination barrier was released; otherwise, false.</returns>
public bool MarkCoordinationPoint()
{
AsyncBarrier? maybeBarrier = Interlocked.Exchange(ref this._coordinationBarrier, null);
return maybeBarrier?.ReleaseBarrier() ?? false;
}
}
@@ -1,7 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -13,62 +13,73 @@ internal class ChatProtocolExecutorOptions
public ChatRole? StringMessageChatRole { get; set; }
}
internal abstract class ChatProtocolExecutor(string id, ChatProtocolExecutorOptions? options = null) : Executor(id)
// TODO: Make this a public type (in a later PR; todo: make an issue)
internal abstract class ChatProtocolExecutor : StatefulExecutor<List<ChatMessage>>
{
private List<ChatMessage> _pendingMessages = [];
private readonly ChatRole? _stringMessageChatRole = options?.StringMessageChatRole;
private readonly static Func<List<ChatMessage>> s_initFunction = () => [];
private readonly ChatRole? _stringMessageChatRole;
// Note that we explicitly do not implement IResettableExecutor here, as we want to allow derived classes to
// implement it if they want to be resettable, but do not want to opt them into it.
protected ValueTask ResetAsync()
internal ChatProtocolExecutor(string id, ChatProtocolExecutorOptions? options = null, bool declareCrossRunShareable = false)
: base(id, () => [], declareCrossRunShareable: declareCrossRunShareable)
{
this._pendingMessages = [];
return default;
this._stringMessageChatRole = options?.StringMessageChatRole;
}
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
if (this._stringMessageChatRole.HasValue)
{
routeBuilder = routeBuilder.AddHandler<string>((message, _, __) => this._pendingMessages.Add(new(this._stringMessageChatRole.Value, message)));
routeBuilder = routeBuilder.AddHandler<string>(
(message, context) => this.AddMessageAsync(new(this._stringMessageChatRole.Value, message), context));
}
// Routing requires exact type matches. The runtime may dispatch either List<ChatMessage> or ChatMessage[].
return routeBuilder.AddHandler<ChatMessage>((message, _, __) => this._pendingMessages.Add(message))
.AddHandler<List<ChatMessage>>((messages, _, __) => this._pendingMessages.AddRange(messages))
.AddHandler<ChatMessage[]>((messages, _, __) => this._pendingMessages.AddRange(messages))
return routeBuilder.AddHandler<ChatMessage>(this.AddMessageAsync)
.AddHandler<IEnumerable<ChatMessage>>(this.AddMessagesAsync)
.AddHandler<ChatMessage[]>(this.AddMessagesAsync)
.AddHandler<List<ChatMessage>>(this.AddMessagesAsync)
.AddHandler<TurnToken>(this.TakeTurnAsync);
}
public async ValueTask TakeTurnAsync(TurnToken token, IWorkflowContext context, CancellationToken cancellationToken = default)
protected ValueTask AddMessageAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
await this.TakeTurnAsync(this._pendingMessages, context, token.EmitEvents, cancellationToken).ConfigureAwait(false);
this._pendingMessages = [];
await context.SendMessageAsync(token, cancellationToken: cancellationToken).ConfigureAwait(false);
return this.InvokeWithStateAsync(ForwardMessageAsync, context, cancellationToken: cancellationToken);
ValueTask<List<ChatMessage>?> ForwardMessageAsync(List<ChatMessage>? maybePendingMessages, IWorkflowContext context, CancellationToken cancelationToken)
{
maybePendingMessages ??= s_initFunction();
maybePendingMessages.Add(message);
return new(maybePendingMessages);
}
}
protected ValueTask AddMessagesAsync(IEnumerable<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken = default)
{
return this.InvokeWithStateAsync(ForwardMessageAsync, context, cancellationToken: cancellationToken);
ValueTask<List<ChatMessage>?> ForwardMessageAsync(List<ChatMessage>? maybePendingMessages, IWorkflowContext context, CancellationToken cancelationToken)
{
maybePendingMessages ??= s_initFunction();
maybePendingMessages.AddRange(messages);
return new(maybePendingMessages);
}
}
public ValueTask TakeTurnAsync(TurnToken token, IWorkflowContext context, CancellationToken cancellationToken = default)
{
return this.InvokeWithStateAsync(InvokeTakeTurnAsync, context, cancellationToken: cancellationToken);
async ValueTask<List<ChatMessage>?> InvokeTakeTurnAsync(List<ChatMessage>? maybePendingMessages, IWorkflowContext context, CancellationToken cancellationToken)
{
await this.TakeTurnAsync(maybePendingMessages ?? s_initFunction(), context, token.EmitEvents, cancellationToken)
.ConfigureAwait(false);
await context.SendMessageAsync(token, cancellationToken: cancellationToken).ConfigureAwait(false);
// Rerun the initialStateFactory to reset the state to empty list. (We could return the empty list directly,
// but this is more consistent if the initial state factory becomes more complex.)
return s_initFunction();
}
}
protected abstract ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default);
private const string PendingMessagesStateKey = nameof(_pendingMessages);
protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
Task messagesTask = Task.CompletedTask;
if (this._pendingMessages.Count > 0)
{
JsonElement messagesValue = this._pendingMessages.Serialize();
messagesTask = context.QueueStateUpdateAsync(PendingMessagesStateKey, messagesValue, cancellationToken: cancellationToken).AsTask();
}
await messagesTask.ConfigureAwait(false);
}
protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
JsonElement? messagesValue = await context.ReadStateAsync<JsonElement?>(PendingMessagesStateKey, cancellationToken: cancellationToken).ConfigureAwait(false);
if (messagesValue.HasValue)
{
List<ChatMessage> messages = messagesValue.Value.DeserializeMessages();
this._pendingMessages.AddRange(messages);
}
}
}
@@ -9,9 +9,11 @@ namespace Microsoft.Agents.AI.Workflows.Execution;
internal interface ISuperStepJoinContext
{
bool WithCheckpointing { get; }
bool ConcurrentRunsEnabled { get; }
ValueTask ForwardWorkflowEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default);
ValueTask SendMessageAsync<TMessage>(string senderId, [DisallowNull] TMessage message, CancellationToken cancellationToken = default);
ValueTask AttachSuperstepAsync(ISuperStepRunner superStepRunner, CancellationToken cancellationToken = default);
ValueTask<string> AttachSuperstepAsync(ISuperStepRunner superStepRunner, CancellationToken cancellationToken = default);
ValueTask<bool> DetachSuperstepAsync(string id);
}
@@ -1,45 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
namespace Microsoft.Agents.AI.Workflows.Execution;
internal class InitLocked<T>() where T : class
{
private int _writers;
private T? _value;
public T? Get()
{
return this._value;
}
public bool Init(Func<T> initializer)
{
if (Interlocked.Exchange(ref this._writers, 1) == 0)
{
try
{
if (this._value == null)
{
this._value = initializer();
return true;
}
return false;
}
finally
{
this._writers = 0;
}
}
return false;
}
public void Clear()
{
this._value = null;
}
}
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Checkpointing;
@@ -97,7 +98,10 @@ internal sealed class StateManager
public ValueTask<T?> ReadStateAsync<T>(string executorId, string? scopeName, string key)
=> this.ReadStateAsync<T>(new ScopeId(Throw.IfNullOrEmpty(executorId), scopeName), key);
public ValueTask<T?> ReadStateAsync<T>(ScopeId scopeId, string key)
public ValueTask<T> ReadOrInitStateAsync<T>(string executorId, string? scopeName, string key, Func<T> initialStateFactory)
=> this.ReadOrInitStateAsync(new ScopeId(Throw.IfNullOrEmpty(executorId), scopeName), key, initialStateFactory);
private async ValueTask<T?> ReadValueOrDefaultAsync<T>(ScopeId scopeId, string key, Func<T>? defaultValueFactory = default, bool initOnDefault = false)
{
if (typeof(T) == typeof(object))
{
@@ -110,35 +114,65 @@ internal sealed class StateManager
UpdateKey stateKey = new(scopeId, key);
T? result = defaultValueFactory != null ? defaultValueFactory() : default;
bool needsInit = false;
// If there is executor-local state (from a queued update), read it first
if (this._queuedUpdates.TryGetValue(stateKey, out StateUpdate? result))
if (this._queuedUpdates.TryGetValue(stateKey, out StateUpdate? update))
{
// What's the right thing to do when we have a state object, but it is the wrong type?
if (result.IsDelete)
if (update.IsDelete || update.Value is null)
{
return new((T?)default);
needsInit = initOnDefault;
}
if (result.Value is T)
else if (update.Value is T typed)
{
return new((T?)result.Value);
result = typed;
}
else if (result.Value == null)
else if (typeof(T) == typeof(PortableValue) && update.Value != null)
{
// Technically should only happen if T is nullable, but we don't have the ability to express that
// so we cannot `return new((T?)null);` directly.
return new((T?)default);
result = (T)(object)new PortableValue(update.Value);
}
else if (typeof(T) == typeof(PortableValue))
else
{
return new((T)(object)new PortableValue(result.Value));
throw new InvalidOperationException($"State for key '{key}' in scope '{scopeId}' is not of type '{typeof(T).Name}'.");
}
}
else
{
StateScope scope = this.GetOrCreateScope(scopeId);
if (scope.ContainsKey(key))
{
result = await scope.ReadStateAsync<T>(key).ConfigureAwait(false);
}
else if (initOnDefault)
{
needsInit = true;
}
throw new InvalidOperationException($"State for key '{key}' in scope '{scopeId}' is not of type '{typeof(T).Name}'.");
}
StateScope scope = this.GetOrCreateScope(scopeId);
return scope.ReadStateAsync<T>(key);
if (needsInit)
{
if (defaultValueFactory is null)
{
throw new ArgumentNullException(nameof(defaultValueFactory), "Default value must be provided when initializing state.");
}
Debug.Assert(initOnDefault);
await this.WriteStateAsync(scopeId, key, defaultValueFactory()).ConfigureAwait(false);
}
return result;
}
public ValueTask<T?> ReadStateAsync<T>(ScopeId scopeId, string key)
=> this.ReadValueOrDefaultAsync<T>(scopeId, key);
public async ValueTask<T> ReadOrInitStateAsync<T>(ScopeId scopeId, string key, Func<T> initialStateFactory)
{
return (await this.ReadValueOrDefaultAsync(scopeId, key, initialStateFactory, initOnDefault: true)
.ConfigureAwait(false))!;
}
public ValueTask WriteStateAsync<T>(string executorId, string? scopeName, string key, T value)
@@ -32,12 +32,25 @@ public abstract class Executor : IIdentified
/// </summary>
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
protected Executor(string id, ExecutorOptions? options = null)
/// <param name="declareCrossRunShareable">Declare that this executor may be used simultaneously by multiple runs safely.</param>
protected Executor(string id, ExecutorOptions? options = null, bool declareCrossRunShareable = false)
{
this.Id = id;
this.Options = options ?? ExecutorOptions.Default;
//if (declareCrossRunShareable && this is IResettableExecutor)
//{
// // We need a way to be able to let the user override this at the workflow level too, because knowing the fine
// // details of when to use which of these paths seems like it could be tricky, and we should not force users
// // to do this; instead container agents should set this when they intiate the run (via WorkflowHostAgent).
// throw new ArgumentException("An executor that is declared as cross-run shareable cannot also be resettable.");
//}
this.IsCrossRunShareable = declareCrossRunShareable;
}
internal bool IsCrossRunShareable { get; }
/// <summary>
/// Gets the configuration options for the executor.
/// </summary>
@@ -48,6 +61,16 @@ public abstract class Executor : IIdentified
/// </summary>
protected abstract RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder);
/// <summary>
/// Perform any asynchronous initialization required by the executor. This method is called once per executor instance,
/// </summary>
/// <param name="context">The workflow context in which the executor executes.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
/// The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
protected internal virtual ValueTask InitializeAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
=> default;
/// <summary>
/// Override this method to declare the types of messages this executor can send.
/// </summary>
@@ -206,8 +229,9 @@ public abstract class Executor : IIdentified
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
public abstract class Executor<TInput>(string id, ExecutorOptions? options = null)
: Executor(id, options), IMessageHandler<TInput>
/// <param name="declareCrossRunShareable">Declare that this executor may be used simultaneously by multiple runs safely.</param>
public abstract class Executor<TInput>(string id, ExecutorOptions? options = null, bool declareCrossRunShareable = false)
: Executor(id, options, declareCrossRunShareable), IMessageHandler<TInput>
{
/// <inheritdoc/>
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
@@ -224,8 +248,9 @@ public abstract class Executor<TInput>(string id, ExecutorOptions? options = nul
/// <typeparam name="TOutput">The type of output message.</typeparam>
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
public abstract class Executor<TInput, TOutput>(string id, ExecutorOptions? options = null)
: Executor(id, options ?? ExecutorOptions.Default),
/// <param name="declareCrossRunShareable">Declare that this executor may be used simultaneously by multiple runs safely.</param>
public abstract class Executor<TInput, TOutput>(string id, ExecutorOptions? options = null, bool declareCrossRunShareable = false)
: Executor(id, options ?? ExecutorOptions.Default, declareCrossRunShareable),
IMessageHandler<TInput, TOutput>
{
/// <inheritdoc/>
@@ -13,6 +13,43 @@ namespace Microsoft.Agents.AI.Workflows;
/// </summary>
public static class ExecutorIshConfigurationExtensions
{
/// <summary>
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, using the
/// type name as the id.
/// </summary>
/// <remarks>
/// Note that Executor Ids must be unique within a workflow.
///
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
/// for it, if this is used as a start node of a typed <see cref="Workflow{TInput}"/> via <see cref="Workflow.TryPromoteAsync{TInput}"/>,
/// it will be instantiated as part of the workflow's construction, to validate that its input type matches the
/// demanded <c>TInput</c>.
/// </remarks>
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
/// <param name="factoryAsync">The factory method.</param>
/// <returns>An ExecutorIsh instance that resolves to the result of the factory call when messages get sent to it.</returns>
public static ExecutorIsh ConfigureFactory<TExecutor>(this Func<string, string, ValueTask<TExecutor>> factoryAsync)
where TExecutor : Executor
=> ConfigureFactory<TExecutor, ExecutorOptions>((config, runId) => factoryAsync(config.Id, runId), typeof(TExecutor).Name, options: null);
/// <summary>
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, with
/// the specified id.
/// </summary>
/// <remarks>
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
/// for it, if this is used as a start node of a typed <see cref="Workflow{TInput}"/> via <see cref="Workflow.TryPromoteAsync{TInput}"/>,
/// it will be instantiated as part of the workflow's construction, to validate that its input type matches the
/// demanded <c>TInput</c>.
/// </remarks>
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
/// <param name="factoryAsync">The factory method.</param>
/// <param name="id">An id for the executor to be instantiated.</param>
/// <returns>An ExecutorIsh instance that resolves to the result of the factory call when messages get sent to it.</returns>
public static ExecutorIsh ConfigureFactory<TExecutor>(this Func<string, string, ValueTask<TExecutor>> factoryAsync, string id)
where TExecutor : Executor
=> ConfigureFactory<TExecutor, ExecutorOptions>((_, runId) => factoryAsync(id, runId), id, options: null);
/// <summary>
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, with
/// the specified id and options.
@@ -77,9 +114,10 @@ public static class ExecutorIshConfigurationExtensions
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
/// <param name="id">A optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An ExecutorIsh instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorIsh AsExecutor<TInput>(this Func<TInput, IWorkflowContext, CancellationToken, ValueTask> messageHandlerAsync, string id, ExecutorOptions? options = null)
=> new FunctionExecutor<TInput>(id, messageHandlerAsync, options).ToExecutorIsh(messageHandlerAsync);
public static ExecutorIsh AsExecutor<TInput>(this Func<TInput, IWorkflowContext, CancellationToken, ValueTask> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> new FunctionExecutor<TInput>(id, messageHandlerAsync, options, declareCrossRunShareable: threadsafe).ToExecutorIsh(messageHandlerAsync);
/// <summary>
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
@@ -90,9 +128,10 @@ public static class ExecutorIshConfigurationExtensions
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An ExecutorIsh instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorIsh AsExecutor<TInput, TOutput>(this Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>> messageHandlerAsync, string id, ExecutorOptions? options = null)
=> new FunctionExecutor<TInput, TOutput>(Throw.IfNull(id), messageHandlerAsync, options).ToExecutorIsh(messageHandlerAsync);
public static ExecutorIsh AsExecutor<TInput, TOutput>(this Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> new FunctionExecutor<TInput, TOutput>(Throw.IfNull(id), messageHandlerAsync, options, declareCrossRunShareable: threadsafe).ToExecutorIsh(messageHandlerAsync);
/// <summary>
/// Configures a function-based aggregating executor with the specified identifier and options.
@@ -102,9 +141,10 @@ public static class ExecutorIshConfigurationExtensions
/// <param name="aggregatorFunc">A delegate the defines the aggregation procedure</param>
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An ExecutorIsh instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorIsh AsExecutor<TInput, TAccumulate>(this Func<TAccumulate?, TInput, TAccumulate?> aggregatorFunc, string id, ExecutorOptions? options = null)
=> new AggregatingExecutor<TInput, TAccumulate>(id, aggregatorFunc, options);
public static ExecutorIsh AsExecutor<TInput, TAccumulate>(this Func<TAccumulate?, TInput, TAccumulate?> aggregatorFunc, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> new AggregatingExecutor<TInput, TAccumulate>(id, aggregatorFunc, options, declareCrossRunShareable: threadsafe);
}
/// <summary>
@@ -14,7 +14,13 @@ internal sealed class ExecutorRegistration(string id, Type executorType, Executo
public Type ExecutorType { get; } = Throw.IfNull(executorType);
private ExecutorFactoryF ProviderAsync { get; } = Throw.IfNull(provider);
public bool IsNotExecutorInstance { get; } = rawData is not Executor;
public bool IsUnresettableSharedInstance { get; } = rawData is Executor && rawData is not IResettableExecutor;
public bool IsUnresettableSharedInstance { get; } = rawData is Executor executor &&
// Cross-Run Shareable executors are "trivially" resettable, since they
// have no on-object state.
!executor.IsCrossRunShareable &&
rawData is not IResettableExecutor;
public bool SupportsConcurrent { get; } = (rawData is not Executor executor || executor.IsCrossRunShareable) &&
(rawData is not Workflow workflow || workflow.AllowConcurrent);
internal async ValueTask<bool> TryResetAsync()
{
@@ -23,9 +29,8 @@ internal sealed class ExecutorRegistration(string id, Type executorType, Executo
return false;
}
// If this is not an executor instance, this is a factory, and the expectation is that the factory will
// create separate instances of executors.
if (this.IsNotExecutorInstance)
// If the executor supports concurrent use, then resetting is a no-op.
if (this.SupportsConcurrent)
{
return true;
}
@@ -13,9 +13,11 @@ namespace Microsoft.Agents.AI.Workflows;
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="handlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="declareCrossRunShareable">Declare that this executor may be used simultaneously by multiple runs safely.</param>
public class FunctionExecutor<TInput>(string id,
Func<TInput, IWorkflowContext, CancellationToken, ValueTask> handlerAsync,
ExecutorOptions? options = null) : Executor<TInput>(id, options)
ExecutorOptions? options = null,
bool declareCrossRunShareable = false) : Executor<TInput>(id, options, declareCrossRunShareable)
{
internal static Func<TInput, IWorkflowContext, CancellationToken, ValueTask> WrapAction(Action<TInput, IWorkflowContext, CancellationToken> handlerSync)
{
@@ -49,9 +51,11 @@ public class FunctionExecutor<TInput>(string id,
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="handlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="declareCrossRunShareable">Declare that this executor may be used simultaneously by multiple runs safely.</param>
public class FunctionExecutor<TInput, TOutput>(string id,
Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>> handlerAsync,
ExecutorOptions? options = null) : Executor<TInput, TOutput>(id, options)
ExecutorOptions? options = null,
bool declareCrossRunShareable = false) : Executor<TInput, TOutput>(id, options, declareCrossRunShareable)
{
internal static Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>> WrapFunc(Func<TInput, IWorkflowContext, CancellationToken, TOutput> handlerSync)
{
@@ -0,0 +1,83 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <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">The <see cref="CancellationToken"/> to monitor for cancellation requests.
/// The default is <see cref="CancellationToken.None"/>.</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">The <see cref="CancellationToken"/> to monitor for cancellation requests.
/// The default is <see cref="CancellationToken.None"/>.</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">The <see cref="CancellationToken"/> to monitor for cancellation requests.
/// The default is <see cref="CancellationToken.None"/>.</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;
}
}
@@ -0,0 +1,70 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <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"/> 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 Build()
{
AIAgent[] agents = this._participants.ToArray();
Dictionary<AIAgent, ExecutorIsh> agentMap = agents.ToDictionary(a => a, a => (ExecutorIsh)new AgentRunStreamingExecutor(a, includeInputInOutput: true));
Func<string, string, ValueTask<Executor>> groupChatHostFactory =
(string id, string runId) => new(new GroupChatHost(id, agents, agentMap, this._managerFactory));
ExecutorIsh host = groupChatHostFactory.ConfigureFactory(nameof(GroupChatHost));
WorkflowBuilder builder = new(host);
foreach (var participant in agentMap.Values)
{
builder
.AddEdge(host, participant)
.AddEdge(participant, host);
}
return builder.WithOutputFrom(host).Build();
}
}
@@ -0,0 +1,168 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Provides a builder for specifying the handoff relationships between agents and building the resulting workflow.
/// </summary>
public sealed class HandoffsWorkflowBuilder
{
internal const string FunctionPrefix = "handoff_to_";
private readonly AIAgent _initialAgent;
private readonly Dictionary<AIAgent, HashSet<HandoffTarget>> _targets = [];
private readonly HashSet<AIAgent> _allAgents = new(AIAgentIDEqualityComparer.Instance);
/// <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);
}
/// <summary>
/// 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
/// any additional instructions, or may be customized to provide more specific guidance.
/// </remarks>
public string? HandoffInstructions { get; set; } =
$"""
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>
/// 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 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);
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 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"/>.
/// 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);
this._allAgents.Add(from);
this._allAgents.Add(to);
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 Build()
{
HandoffsStartExecutor start = new();
HandoffsEndExecutor end = new();
WorkflowBuilder builder = new(start);
// Create an AgentExecutor for each again.
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]);
// Initialize each executor with its handoff targets to the other executors.
foreach (var agent in this._allAgents)
{
executors[agent.Id].Initialize(builder, end, executors,
this._targets.TryGetValue(agent, out HashSet<HandoffTarget>? targets) ? targets : []);
}
// Build the workflow.
return builder.WithOutputFrom(end).Build();
}
}
@@ -1,11 +1,66 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Provides extension methods for working with <see cref="IWorkflowContext"/> instances.
/// </summary>
public static class WorkflowContextExtensions
{
/// <summary>
/// Invokes an asynchronous operation that reads, updates, and persists workflow state associated with the specified
/// key.
/// </summary>
/// <typeparam name="TState">The type of the state object to read, update, and persist.</typeparam>
/// <param name="context">The workflow context used to access and update state.</param>
/// <param name="invocation">A delegate that receives the current state, workflow context, and cancellation token, and returns the updated
/// state asynchronously.</param>
/// <param name="key">The key identifying the state to read and update. Cannot be null or empty.</param>
/// <param name="scopeName">An optional scope name that further qualifies the state key. If null, the default scope is used.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation.</param>
/// <returns>A ValueTask that represents the asynchronous operation.</returns>
public static async ValueTask InvokeWithStateAsync<TState>(this IWorkflowContext context,
Func<TState?, IWorkflowContext, CancellationToken, ValueTask<TState?>> invocation,
string key,
string? scopeName = null,
CancellationToken cancellationToken = default)
{
TState? state = await context.ReadStateAsync<TState>(key, scopeName, cancellationToken).ConfigureAwait(false);
state = await invocation(state, context, cancellationToken).ConfigureAwait(false);
await context.QueueStateUpdateAsync(key, state, scopeName, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Invokes an asynchronous operation that reads, updates, and persists workflow state associated with the specified
/// key.
/// </summary>
/// <typeparam name="TState">The type of the state object to read, update, and persist.</typeparam>
/// <param name="context">The workflow context used to access and update state.</param>
/// <param name="invocation">A delegate that receives the current state, workflow context, and cancellation token, and returns the updated
/// state asynchronously.</param>
/// <param name="key">The key identifying the state to read and update. Cannot be null or empty.</param>
/// <param name="initialStateFactory">A factory to initialize state to if it is not set at the provided key.</param>
/// <param name="scopeName">An optional scope name that further qualifies the state key. If null, the default scope is used.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation.</param>
/// <returns>A ValueTask that represents the asynchronous operation.</returns>
public static async ValueTask InvokeWithStateAsync<TState>(this IWorkflowContext context,
Func<TState, IWorkflowContext, CancellationToken, ValueTask<TState?>> invocation,
string key,
Func<TState> initialStateFactory,
string? scopeName = null,
CancellationToken cancellationToken = default)
{
TState? state = await context.ReadOrInitStateAsync(key, initialStateFactory, scopeName, cancellationToken).ConfigureAwait(false);
state = await invocation(state, context, cancellationToken).ConfigureAwait(false);
await context.QueueStateUpdateAsync(key, state ?? initialStateFactory(), scopeName, cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Provides services for an <see cref="Executor"/> during the execution of a workflow.
/// </summary>
@@ -78,6 +133,24 @@ public interface IWorkflowContext
/// <returns>A <see cref="ValueTask{T}"/> representing the asynchronous operation.</returns>
ValueTask<T?> ReadStateAsync<T>(string key, string? scopeName = null, CancellationToken cancellationToken = default);
/// <summary>
/// Reads or initialized a state value from the workflow's state store. If no scope is provided, the executor's
/// default scope is used.
/// </summary>
/// <remarks>
/// When initializing the state, the state will be queued as an update. If multiple initializations are done in the same
/// SuperStep from different executors, an error will be generated at the end of the SuperStep.
/// </remarks>
/// <typeparam name="T">The type of the state value.</typeparam>
/// <param name="key">The key of the state value.</param>
/// <param name="initialStateFactory">A factory to initialize the state if the key has no value associated with it.</param>
/// <param name = "scopeName" > An optional name that specifies the scope to read. If null, the default scope is
/// used.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
/// The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ValueTask{T}"/> representing the asynchronous operation.</returns>
ValueTask<T> ReadOrInitStateAsync<T>(string key, Func<T> initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default);
#if NET // See above for musings about this construction
/// <summary>
/// Reads a state value from the workflow's state store. If no scope is provided, the executor's
@@ -87,8 +160,21 @@ public interface IWorkflowContext
/// <param name="key">The key of the state value.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A <see cref="ValueTask{T}"/> representing the asynchronous operation.</returns>
ValueTask<T?> ReadStateAsync<T>(string key, CancellationToken cancellationToken) => this.ReadStateAsync<T>(key, null, cancellationToken);
ValueTask<T?> ReadStateAsync<T>(string key, CancellationToken cancellationToken)
=> this.ReadStateAsync<T>(key, null, cancellationToken);
/// <summary>
/// Reads a state value from the workflow's state store. If no scope is provided, the executor's
/// default scope is used.
/// </summary>
/// <typeparam name="T">The type of the state value.</typeparam>
/// <param name="key">The key of the state value.</param>
/// <param name="initialStateFactory">A factory to initialize the state if the key has no value associated with it.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
/// The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ValueTask{T}"/> representing the asynchronous operation.</returns>
ValueTask<T> ReadOrInitStateAsync<T>(string key, Func<T> initialStateFactory, CancellationToken cancellationToken)
=> this.ReadOrInitStateAsync(key, initialStateFactory, null, cancellationToken);
#endif
/// <summary>
@@ -169,4 +255,9 @@ public interface IWorkflowContext
/// The trace context associated with the current message about to be processed by the executor, if any.
/// </summary>
IReadOnlyDictionary<string, string>? TraceContext { get; }
/// <summary>
/// Whether the current execution environment support concurrent runs against the same workflow instance.
/// </summary>
bool ConcurrentRunsEnabled { get; }
}
@@ -15,22 +15,25 @@ namespace Microsoft.Agents.AI.Workflows.InProc;
/// </summary>
public sealed class InProcessExecutionEnvironment : IWorkflowExecutionEnvironment
{
private readonly ExecutionMode _executionMode;
internal InProcessExecutionEnvironment(ExecutionMode mode)
internal InProcessExecutionEnvironment(ExecutionMode mode, bool enableConcurrentRuns = false)
{
this._executionMode = mode;
this.ExecutionMode = mode;
this.EnableConcurrentRuns = enableConcurrentRuns;
}
internal ExecutionMode ExecutionMode { get; }
internal bool EnableConcurrentRuns { get; }
internal ValueTask<AsyncRunHandle> BeginRunAsync(Workflow workflow, ICheckpointManager? checkpointManager, string? runId, IEnumerable<Type> knownValidInputTypes, CancellationToken cancellationToken)
{
InProcessRunner runner = new(workflow, checkpointManager, runId, knownValidInputTypes: knownValidInputTypes);
return runner.BeginStreamAsync(this._executionMode, cancellationToken);
InProcessRunner runner = InProcessRunner.CreateTopLevelRunner(workflow, checkpointManager, runId, this.EnableConcurrentRuns, knownValidInputTypes);
return runner.BeginStreamAsync(this.ExecutionMode, cancellationToken);
}
internal ValueTask<AsyncRunHandle> ResumeRunAsync(Workflow workflow, ICheckpointManager? checkpointManager, string? runId, CheckpointInfo fromCheckpoint, IEnumerable<Type> knownValidInputTypes, CancellationToken cancellationToken)
{
InProcessRunner runner = new(workflow, checkpointManager, runId, knownValidInputTypes: knownValidInputTypes);
return runner.ResumeStreamAsync(this._executionMode, fromCheckpoint, cancellationToken);
InProcessRunner runner = InProcessRunner.CreateTopLevelRunner(workflow, checkpointManager, runId, this.EnableConcurrentRuns, knownValidInputTypes);
return runner.ResumeStreamAsync(this.ExecutionMode, fromCheckpoint, cancellationToken);
}
/// <inheritdoc/>
@@ -0,0 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Workflows.InProc;
internal class InProcessExecutionOptions
{
public ExecutionMode ExecutionMode { get; init; } = InProcessExecution.Default.ExecutionMode;
public bool AllowSharedWorkflow { get; init; }
}
@@ -21,21 +21,46 @@ namespace Microsoft.Agents.AI.Workflows.InProc;
/// scenarios where workflow execution does not require executor distribution. </para></remarks>
internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle
{
public InProcessRunner(Workflow workflow, ICheckpointManager? checkpointManager, string? runId = null, object? workflowOwnership = null, bool subworkflow = false, IEnumerable<Type>? knownValidInputTypes = null)
public static InProcessRunner CreateTopLevelRunner(Workflow workflow, ICheckpointManager? checkpointManager, string? runId = null, bool enableConcurrentRuns = false, IEnumerable<Type>? knownValidInputTypes = null)
{
return new InProcessRunner(workflow,
checkpointManager,
runId,
enableConcurrentRuns: enableConcurrentRuns,
knownValidInputTypes: knownValidInputTypes);
}
public static InProcessRunner CreateSubworkflowRunner(Workflow workflow, ICheckpointManager? checkpointManager, string? runId = null, object? existingOwnerSignoff = null, bool enableConcurrentRuns = false, IEnumerable<Type>? knownValidInputTypes = null)
{
return new InProcessRunner(workflow,
checkpointManager,
runId,
existingOwnerSignoff: existingOwnerSignoff,
enableConcurrentRuns: enableConcurrentRuns,
knownValidInputTypes: knownValidInputTypes,
subworkflow: true);
}
private InProcessRunner(Workflow workflow, ICheckpointManager? checkpointManager, string? runId = null, object? existingOwnerSignoff = null, bool subworkflow = false, bool enableConcurrentRuns = false, IEnumerable<Type>? knownValidInputTypes = null)
{
if (enableConcurrentRuns && !workflow.AllowConcurrent)
{
throw new InvalidOperationException("Workflow must only consist of cross-run share-capable or factory-created executors. Executors " +
$"not supporting concurrent: {string.Join(", ", workflow.NonConcurrentExecutorIds)}");
}
this.RunId = runId ?? Guid.NewGuid().ToString("N");
this.StartExecutorId = workflow.StartExecutorId;
this.Workflow = Throw.IfNull(workflow);
this.RunContext = new InProcessRunnerContext(workflow, this.RunId, withCheckpointing: checkpointManager != null, this.OutgoingEvents, this.StepTracer, workflowOwnership, subworkflow);
this.RunContext = new InProcessRunnerContext(workflow, this.RunId, withCheckpointing: checkpointManager != null, this.OutgoingEvents, this.StepTracer, existingOwnerSignoff, subworkflow, enableConcurrentRuns);
this.CheckpointManager = checkpointManager;
this._knownValidInputTypes = knownValidInputTypes != null
? [.. knownValidInputTypes]
: [];
// Initialize the runners for each of the edges, along with the state for edges that
// need it.
// Initialize the runners for each of the edges, along with the state for edges that need it.
this.EdgeMap = new EdgeMap(this.RunContext, this.Workflow.Edges, this.Workflow.Ports.Values, this.Workflow.StartExecutorId, this.StepTracer);
}
@@ -32,7 +32,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext
private readonly ConcurrentDictionary<string, Task<Executor>> _executors = new();
private readonly ConcurrentQueue<Func<ValueTask>> _queuedExternalDeliveries = new();
private readonly ConcurrentQueue<ISuperStepRunner> _joinedSubworkflowRunners = new();
private readonly ConcurrentDictionary<string, ISuperStepRunner> _joinedSubworkflowRunners = new();
private readonly ConcurrentDictionary<string, ExternalRequest> _externalRequests = new();
@@ -42,11 +42,19 @@ internal sealed class InProcessRunnerContext : IRunnerContext
bool withCheckpointing,
IEventSink outgoingEvents,
IStepTracer? stepTracer,
object? workflowOwnership = null,
object? existingOwnershipSignoff = null,
bool subworkflow = false,
bool enableConcurrentRuns = false,
ILogger? logger = null)
{
workflow.TakeOwnership(this, existingOwnershipSignoff: workflowOwnership);
if (enableConcurrentRuns)
{
workflow.CheckOwnership(existingOwnershipSignoff: existingOwnershipSignoff);
}
else
{
workflow.TakeOwnership(this, existingOwnershipSignoff: existingOwnershipSignoff);
}
this._workflow = workflow;
this._runId = runId;
@@ -54,6 +62,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext
this._outputFilter = new(workflow);
this.WithCheckpointing = withCheckpointing;
this.ConcurrentRunsEnabled = enableConcurrentRuns;
this.OutgoingEvents = outgoingEvents;
}
@@ -70,6 +79,9 @@ internal sealed class InProcessRunnerContext : IRunnerContext
}
Executor executor = await registration.CreateInstanceAsync(this._runId).ConfigureAwait(false);
await executor.InitializeAsync(this.Bind(executorId), cancellationToken: cancellationToken)
.ConfigureAwait(false);
tracer?.TraceActivated(executorId);
if (executor is RequestInfoExecutor requestInputExecutor)
@@ -138,12 +150,13 @@ internal sealed class InProcessRunnerContext : IRunnerContext
}
public bool HasQueuedExternalDeliveries => !this._queuedExternalDeliveries.IsEmpty;
public bool JoinedRunnersHaveActions => this._joinedSubworkflowRunners.Any(joinedRunner => joinedRunner.HasUnprocessedMessages);
public bool JoinedRunnersHaveActions => this._joinedSubworkflowRunners.Values.Any(runner => runner.HasUnprocessedMessages);
public bool NextStepHasActions => this._nextStep.HasMessages ||
this.HasQueuedExternalDeliveries ||
this.JoinedRunnersHaveActions;
public bool HasUnservicedRequests => !this._externalRequests.IsEmpty ||
this._joinedSubworkflowRunners.Any(joinedRunner => joinedRunner.HasUnservicedRequests);
this._joinedSubworkflowRunners.Values.Any(runner => runner.HasUnservicedRequests);
public async ValueTask<StepContext> AdvanceAsync(CancellationToken cancellationToken = default)
{
@@ -260,6 +273,10 @@ internal sealed class InProcessRunnerContext : IRunnerContext
public ValueTask<T?> ReadStateAsync<T>(string key, string? scopeName = null, CancellationToken cancellationToken = default)
=> RunnerContext.StateManager.ReadStateAsync<T>(ExecutorId, scopeName, key);
[return: NotNull]
public ValueTask<T> ReadOrInitStateAsync<T>(string key, Func<T> initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default)
=> RunnerContext.StateManager.ReadOrInitStateAsync(ExecutorId, scopeName, key, initialStateFactory);
public ValueTask<HashSet<string>> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default)
=> RunnerContext.StateManager.ReadKeysAsync(ExecutorId, scopeName);
@@ -270,9 +287,12 @@ internal sealed class InProcessRunnerContext : IRunnerContext
=> RunnerContext.StateManager.ClearStateAsync(ExecutorId, scopeName);
public IReadOnlyDictionary<string, string>? TraceContext => traceContext;
public bool ConcurrentRunsEnabled => RunnerContext.ConcurrentRunsEnabled;
}
public bool WithCheckpointing { get; }
public bool ConcurrentRunsEnabled { get; }
internal Task PrepareForCheckpointAsync(CancellationToken cancellationToken = default)
{
@@ -380,20 +400,30 @@ internal sealed class InProcessRunnerContext : IRunnerContext
}
}
await this._workflow.ReleaseOwnershipAsync(this).ConfigureAwait(false);
if (!this.ConcurrentRunsEnabled)
{
await this._workflow.ReleaseOwnershipAsync(this).ConfigureAwait(false);
}
}
}
public IEnumerable<ISuperStepRunner> JoinedSubworkflowRunners => this._joinedSubworkflowRunners;
public IEnumerable<ISuperStepRunner> JoinedSubworkflowRunners => this._joinedSubworkflowRunners.Values;
public ValueTask AttachSuperstepAsync(ISuperStepRunner superStepRunner, CancellationToken cancellationToken = default)
public ValueTask<string> AttachSuperstepAsync(ISuperStepRunner superStepRunner, CancellationToken cancellationToken = default)
{
// This needs to be a thread-safe ordered collection because we can potentially instantiate executors
// in parallel, which means multiple sub-workflows could be attaching at the same time.
this._joinedSubworkflowRunners.Enqueue(superStepRunner);
string joinId;
do
{
joinId = Guid.NewGuid().ToString("N");
} while (!this._joinedSubworkflowRunners.TryAdd(joinId, superStepRunner));
return default;
}
public ValueTask<bool> DetachSuperstepAsync(string joinId) => new(this._joinedSubworkflowRunners.TryRemove(joinId, out _));
ValueTask ISuperStepJoinContext.ForwardWorkflowEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken)
=> this.AddEventAsync(workflowEvent, cancellationToken);
@@ -23,6 +23,11 @@ public static class InProcessExecution
/// </summary>
public static InProcessExecutionEnvironment OffThread { get; } = new(ExecutionMode.OffThread);
/// <summary>
/// Gets an execution environment that enables concurrent, off-thread in-process execution.
/// </summary>
public static InProcessExecutionEnvironment Concurrent { get; } = new(ExecutionMode.OffThread, enableConcurrentRuns: true);
/// <summary>
/// An InProcesExecution environment which will run SuperSteps in the event watching thread,
/// accumulating events during each SuperStep and streaming them out after each SuperStep is
@@ -16,8 +16,9 @@ public class ReflectingExecutor<
] TExecutor
> : Executor where TExecutor : ReflectingExecutor<TExecutor>
{
/// <inheritdoc cref="Executor(string, ExecutorOptions?)"/>
protected ReflectingExecutor(string id, ExecutorOptions? options = null) : base(id, options)
/// <inheritdoc cref="Executor(string, ExecutorOptions?, bool)"/>
protected ReflectingExecutor(string id, ExecutorOptions? options = null, bool declareCrossRunShareable = false)
: base(id, options, declareCrossRunShareable)
{
}
@@ -0,0 +1,72 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <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;
}
}
@@ -0,0 +1,44 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
/// <summary>
/// Executor that runs the agent and forwards all messages, input and output, to the next executor.
/// </summary>
internal sealed class AgentRunStreamingExecutor(AIAgent agent, bool includeInputInOutput)
: ChatProtocolExecutor(agent.GetDescriptiveId(), DefaultOptions, declareCrossRunShareable: true), IResettableExecutor
{
private static ChatProtocolExecutorOptions DefaultOptions => new()
{
StringMessageChatRole = ChatRole.User
};
protected override async ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
{
List<ChatMessage>? roleChanged = messages.ChangeAssistantToUserForOtherParticipants(agent.DisplayName);
List<AgentRunResponseUpdate> updates = [];
await foreach (var update in agent.RunStreamingAsync(messages, cancellationToken: cancellationToken).ConfigureAwait(false))
{
updates.Add(update);
if (emitEvents is true)
{
await context.AddEventAsync(new AgentRunUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false);
}
}
roleChanged.ResetUserToAssistantForChangedRoles();
List<ChatMessage> result = includeInputInOutput ? [.. messages] : [];
result.AddRange(updates.ToAgentRunResponse().Messages);
await context.SendMessageAsync(result, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public new ValueTask ResetAsync() => base.ResetAsync();
}
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
/// <summary>Executor that forwards all messages.</summary>
internal sealed class ChatForwardingExecutor(string id) : Executor(id, declareCrossRunShareable: true), IResettableExecutor
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder
.AddHandler<string>((message, context, cancellationToken) => context.SendMessageAsync(new ChatMessage(ChatRole.User, message), cancellationToken: cancellationToken))
.AddHandler<ChatMessage>((message, context, cancellationToken) => context.SendMessageAsync(message, cancellationToken: cancellationToken))
.AddHandler<List<ChatMessage>>((messages, context, cancellationToken) => context.SendMessageAsync(messages, cancellationToken: cancellationToken))
.AddHandler<TurnToken>((turnToken, context, cancellationToken) => context.SendMessageAsync(turnToken, cancellationToken: cancellationToken));
public ValueTask ResetAsync() => default;
}
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
/// <summary>
/// Provides an executor that batches received chat messages that it then releases when
/// receiving a <see cref="TurnToken"/>.
/// </summary>
internal sealed class CollectChatMessagesExecutor(string id) : ChatProtocolExecutor(id), IResettableExecutor
{
/// <inheritdoc/>
protected override ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
=> context.SendMessageAsync(messages, cancellationToken: cancellationToken);
ValueTask IResettableExecutor.ResetAsync() => this.ResetAsync();
}
@@ -0,0 +1,64 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Specialized;
/// <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>
internal sealed class ConcurrentEndExecutor : Executor, IResettableExecutor
{
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) : base("ConcurrentEnd")
{
this._expectedInputs = expectedInputs;
this._aggregator = Throw.IfNull(aggregator);
this._allResults = new List<List<ChatMessage>>(expectedInputs);
this._remaining = expectedInputs;
}
private void Reset()
{
this._allResults = new List<List<ChatMessage>>(this._expectedInputs);
this._remaining = this._expectedInputs;
}
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<List<ChatMessage>>(async (messages, context, cancellationToken) =>
{
// 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.YieldOutputAsync(this._aggregator(results), cancellationToken).ConfigureAwait(false);
}
});
public ValueTask ResetAsync()
{
this.Reset();
return default;
}
}
@@ -0,0 +1,62 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
internal sealed class GroupChatHost(
string id,
AIAgent[] agents,
Dictionary<AIAgent, ExecutorIsh> agentMap,
Func<IReadOnlyList<AIAgent>, GroupChatManager> managerFactory) : Executor(id), IResettableExecutor
{
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, cancellationToken) =>
{
List<ChatMessage> messages = [.. this._pendingMessages];
this._pendingMessages.Clear();
this._manager ??= this._managerFactory(this._agents);
if (!await this._manager.ShouldTerminateAsync(messages, cancellationToken).ConfigureAwait(false))
{
var filtered = await this._manager.UpdateHistoryAsync(messages, cancellationToken).ConfigureAwait(false);
messages = filtered is null || ReferenceEquals(filtered, messages) ? messages : [.. filtered];
if (await this._manager.SelectNextAgentAsync(messages, cancellationToken).ConfigureAwait(false) is AIAgent nextAgent &&
this._agentMap.TryGetValue(nextAgent, out var executor))
{
this._manager.IterationCount++;
await context.SendMessageAsync(messages, executor.Id, cancellationToken).ConfigureAwait(false);
await context.SendMessageAsync(token, executor.Id, cancellationToken).ConfigureAwait(false);
return;
}
}
this._manager = null;
await context.YieldOutputAsync(messages, cancellationToken).ConfigureAwait(false);
});
public ValueTask ResetAsync()
{
this._pendingMessages.Clear();
this._manager = null;
return default;
}
}
@@ -0,0 +1,116 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
/// <summary>Executor used to represent an agent in a handoffs workflow, responding to <see cref="HandoffState"/> events.</summary>
internal sealed class HandoffAgentExecutor(
AIAgent agent,
string? handoffInstructions) : Executor(agent.GetDescriptiveId()), IResettableExecutor
{
private static readonly JsonElement s_handoffSchema = AIFunctionFactory.Create(
([Description("The reason for the handoff")] string? reasonForHandoff) => { }).JsonSchema;
private readonly AIAgent _agent = agent;
private readonly HashSet<string> _handoffFunctionNames = [];
private ChatClientAgentRunOptions? _agentOptions;
public void Initialize(
WorkflowBuilder builder,
Executor end,
Dictionary<string, HandoffAgentExecutor> executors,
HashSet<HandoffTarget> handoffs) =>
builder.AddSwitch(this, sb =>
{
if (handoffs.Count != 0)
{
Debug.Assert(this._agentOptions is null);
this._agentOptions = new()
{
ChatOptions = new()
{
AllowMultipleToolCalls = false,
Instructions = handoffInstructions,
Tools = [],
},
};
foreach (HandoffTarget handoff in handoffs)
{
var handoffFunc = AIFunctionFactory.CreateDeclaration($"{HandoffsWorkflowBuilder.FunctionPrefix}{handoff.Target.GetDescriptiveId()}", handoff.Reason, s_handoffSchema);
this._handoffFunctionNames.Add(handoffFunc.Name);
this._agentOptions.ChatOptions.Tools.Add(handoffFunc);
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, cancellationToken) =>
{
string? requestedHandoff = null;
List<AgentRunResponseUpdate> updates = [];
List<ChatMessage> allMessages = handoffState.Messages;
List<ChatMessage>? roleChanges = allMessages.ChangeAssistantToUserForOtherParticipants(this._agent.DisplayName);
await foreach (var update in this._agent.RunStreamingAsync(allMessages,
options: this._agentOptions,
cancellationToken: cancellationToken)
.ConfigureAwait(false))
{
await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false);
foreach (var c in update.Contents)
{
if (c is FunctionCallContent fcc && 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,
},
cancellationToken
)
.ConfigureAwait(false);
}
}
}
allMessages.AddRange(updates.ToAgentRunResponse().Messages);
roleChanges.ResetUserToAssistantForChangedRoles();
await context.SendMessageAsync(new HandoffState(handoffState.TurnToken, requestedHandoff, allMessages), cancellationToken: cancellationToken).ConfigureAwait(false);
async Task AddUpdateAsync(AgentRunResponseUpdate update, CancellationToken cancellationToken)
{
updates.Add(update);
if (handoffState.TurnToken.EmitEvents is true)
{
await context.AddEventAsync(new AgentRunUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false);
}
}
});
public ValueTask ResetAsync() => default;
}
@@ -0,0 +1,11 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
internal sealed record class HandoffState(
TurnToken TurnToken,
string? InvokedHandoff,
List<ChatMessage> Messages);
@@ -0,0 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Workflows.Specialized;
/// <summary>Describes a handoff to a specific target <see cref="AIAgent"/>.</summary>
internal 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();
}
@@ -0,0 +1,15 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Workflows.Specialized;
/// <summary>Executor used at the end of a handoff workflow to raise a final completed event.</summary>
internal sealed class HandoffsEndExecutor() : Executor("HandoffEnd"), IResettableExecutor
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<HandoffState>((handoff, context, cancellationToken) =>
context.YieldOutputAsync(handoff.Messages, cancellationToken));
public ValueTask ResetAsync() => default;
}
@@ -0,0 +1,22 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
/// <summary>Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token.</summary>
internal sealed class HandoffsStartExecutor() : ChatProtocolExecutor("HandoffStart", DefaultOptions), IResettableExecutor
{
private static ChatProtocolExecutorOptions DefaultOptions => new()
{
StringMessageChatRole = ChatRole.User
};
protected override ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
=> context.SendMessageAsync(new HandoffState(new(emitEvents), null, messages), cancellationToken: cancellationToken);
public new ValueTask ResetAsync() => base.ResetAsync();
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows;
public static partial class AgentWorkflowBuilder
{
/// <summary>
/// Provides an executor that batches received chat messages that it then publishes as the final result
/// when receiving a <see cref="TurnToken"/>.
/// </summary>
internal sealed class OutputMessagesExecutor() : ChatProtocolExecutor("OutputMessages"), IResettableExecutor
{
protected override ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
=> context.YieldOutputAsync(messages, cancellationToken);
ValueTask IResettableExecutor.ResetAsync() => default;
}
}
@@ -24,6 +24,7 @@ internal class WorkflowHostExecutor : Executor, IAsyncDisposable
private readonly ExecutorOptions _options;
private ISuperStepJoinContext? _joinContext;
private string? _joinId;
private StreamingRun? _run;
[MemberNotNullWhen(true, nameof(_checkpointManager))]
@@ -81,7 +82,11 @@ internal class WorkflowHostExecutor : Executor, IAsyncDisposable
this._checkpointManager = new InMemoryCheckpointManager();
}
this._activeRunner = new(this._workflow, this._checkpointManager, this._runId, this._ownershipToken, subworkflow: true);
this._activeRunner = InProcessRunner.CreateSubworkflowRunner(this._workflow,
this._checkpointManager,
this._runId,
this._ownershipToken,
this.JoinContext.ConcurrentRunsEnabled);
}
return this._activeRunner;
@@ -143,7 +148,7 @@ internal class WorkflowHostExecutor : Executor, IAsyncDisposable
this._run = new(runHandle);
await this._joinContext.AttachSuperstepAsync(activeRunner, cancellationToken).ConfigureAwait(false);
this._joinId = await this._joinContext.AttachSuperstepAsync(activeRunner, cancellationToken).ConfigureAwait(false);
activeRunner.OutgoingEvents.EventRaised += this.ForwardWorkflowEventAsync;
return this._run;
@@ -265,7 +270,18 @@ internal class WorkflowHostExecutor : Executor, IAsyncDisposable
this._activeRunner.OutgoingEvents.EventRaised -= this.ForwardWorkflowEventAsync;
await this._activeRunner.RequestEndRunAsync().ConfigureAwait(false);
this._activeRunner = new(this._workflow, this._checkpointManager, this._runId);
this._activeRunner = null;
}
if (this._joinContext != null)
{
if (this._joinId != null)
{
await this._joinContext.DetachSuperstepAsync(this._joinId).ConfigureAwait(false);
this._joinId = null;
}
this._joinContext = null;
}
}
@@ -0,0 +1,186 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Reflection;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Provides a base class for executors that maintain and manage state across multiple message handling operations.
/// </summary>
/// <typeparam name="TState">The type of state associated with this Executor.</typeparam>
public abstract class StatefulExecutor<TState> : Executor
{
private readonly Func<TState> _initialStateFactory;
private TState? _stateCache;
/// <summary>
/// Initializes the executor with a unique id and an initial value for the state.
/// </summary>
/// <param name="id">The unique identifier for this executor instance. Cannot be null or empty.</param>
/// <param name="initialStateFactory">A factory to initialize the state value to be used by the executor.</param>
/// <param name="options">Optional configuration settings for the executor. If null, default options are used.</param>
/// <param name="declareCrossRunShareable">true to declare that the executor's state can be shared across multiple runs; otherwise, false.</param>
protected StatefulExecutor(string id,
Func<TState> initialStateFactory,
StatefulExecutorOptions? options = null,
bool declareCrossRunShareable = false)
: base(id, options ?? new StatefulExecutorOptions(), declareCrossRunShareable)
{
this.Options = (StatefulExecutorOptions)base.Options;
this._initialStateFactory = Throw.IfNull(initialStateFactory);
}
/// <inheritdoc/>
protected new StatefulExecutorOptions Options { get; }
private string DefaultStateKey => $"{this.GetType().Name}.State";
/// <summary>
/// Gets the key used to identify the executor's state.
/// </summary>
protected string StateKey => this.Options.StateKey ?? this.DefaultStateKey;
/// <summary>
/// Reads the state associated with this executor. If it is not initialized, it will be set to the initial state.
/// </summary>
/// <param name="context">The workflow context in which the executor executes.</param>
/// <param name="skipCache">Ignore the cached value, if any. State is not cached when running in Cross-Run Shareable
/// mode.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
/// The default is <see cref="CancellationToken.None"/>.</param>
/// <returns></returns>
protected async ValueTask<TState> ReadStateAsync(IWorkflowContext context, bool skipCache = false, CancellationToken cancellationToken = default)
{
if (!skipCache && this._stateCache is not null)
{
return this._stateCache;
}
TState? state = await context.ReadOrInitStateAsync(this.StateKey, this._initialStateFactory, this.Options.ScopeName, cancellationToken)
.ConfigureAwait(false);
if (!context.ConcurrentRunsEnabled)
{
this._stateCache = state;
}
return state;
}
/// <summary>
/// Queues up an update to the executor's state.
/// </summary>
/// <param name="state">The new value of state.</param>
/// <param name="context">The workflow context in which the executor executes.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
/// The default is <see cref="CancellationToken.None"/>.</param>
/// <returns></returns>
protected ValueTask QueueStateUpdateAsync(TState state, IWorkflowContext context, CancellationToken cancellationToken = default)
{
if (!context.ConcurrentRunsEnabled)
{
this._stateCache = state;
}
return context.QueueStateUpdateAsync(this.StateKey, state, this.Options.ScopeName, cancellationToken);
}
/// <summary>
/// Invokes an asynchronous operation that reads, updates, and persists workflow state associated with the specified
/// key.
/// </summary>
/// <param name="invocation">A delegate that receives the current state, workflow context, and cancellation token,
/// and returns the updated state asynchronously.</param>
/// <param name="context">The workflow context in which the executor executes.</param>
/// <param name="skipCache">Ignore the cached value, if any. State is not cached when running in Cross-Run Shareable
/// mode.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
/// The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A ValueTask that represents the asynchronous operation.</returns>
protected async ValueTask InvokeWithStateAsync(
Func<TState, IWorkflowContext, CancellationToken, ValueTask<TState?>> invocation,
IWorkflowContext context,
bool skipCache = false,
CancellationToken cancellationToken = default)
{
if (!skipCache && !context.ConcurrentRunsEnabled)
{
TState newState = await invocation(this._stateCache ?? (this._initialStateFactory()),
context,
cancellationToken).ConfigureAwait(false)
?? this._initialStateFactory();
await context.QueueStateUpdateAsync(this.StateKey,
newState,
this.Options.ScopeName,
cancellationToken).ConfigureAwait(false);
this._stateCache = newState;
}
else
{
await context.InvokeWithStateAsync(invocation,
this.StateKey,
this._initialStateFactory,
this.Options.ScopeName,
cancellationToken)
.ConfigureAwait(false);
}
}
/// <inheritdoc cref="IResettableExecutor.ResetAsync"/>
protected ValueTask ResetAsync()
{
this._stateCache = this._initialStateFactory();
return default;
}
}
/// <summary>
/// Provides a simple executor implementation that uses a single message handler function to process incoming messages,
/// and maintain state across invocations.
/// </summary>
/// <typeparam name="TState">The type of state associated with this Executor.</typeparam>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="initialStateFactory">A factory to initialize the state value to be used by the executor.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="declareCrossRunShareable">Declare that this executor may be used simultaneously by multiple runs safely.</param>
public abstract class StatefulExecutor<TState, TInput>(string id, Func<TState> initialStateFactory, StatefulExecutorOptions? options = null, bool declareCrossRunShareable = false)
: StatefulExecutor<TState>(id, initialStateFactory, options, declareCrossRunShareable), IMessageHandler<TInput>
{
/// <inheritdoc/>
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<TInput>(this.HandleAsync);
/// <inheritdoc/>
public abstract ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default);
}
/// <summary>
/// Provides a simple executor implementation that uses a single message handler function to process incoming messages,
/// and maintain state across invocations.
/// </summary>
/// <typeparam name="TState">The type of state associated with this Executor.</typeparam>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <typeparam name="TOutput">The type of output message.</typeparam>
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="initialStateFactory">A factory to initialize the state value to be used by the executor.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="declareCrossRunShareable">Declare that this executor may be used simultaneously by multiple runs safely.</param>
public abstract class StatefulExecutor<TState, TInput, TOutput>(string id, Func<TState> initialStateFactory, StatefulExecutorOptions? options = null, bool declareCrossRunShareable = false)
: StatefulExecutor<TState>(id, initialStateFactory, options, declareCrossRunShareable), IMessageHandler<TInput, TOutput>
{
/// <inheritdoc/>
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<TInput, TOutput>(this.HandleAsync);
/// <inheritdoc/>
public abstract ValueTask<TOutput> HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// .
/// </summary>
public class StatefulExecutorOptions : ExecutorOptions
{
/// <summary>
/// Gets or sets the unique key that identifies the executor's state. If not provided, will default to
/// `{ExecutorType}.State`.
/// </summary>
public string? StateKey { get; set; }
/// <summary>
/// Gets or sets the scope name to use for the executor's state. If not provided, the state will be
/// private to this executor instance.
/// </summary>
public string? ScopeName { get; set; }
}
@@ -66,6 +66,11 @@ public class Workflow
/// </summary>
public string? Description { get; internal init; }
internal bool AllowConcurrent => this.Registrations.Values.All(registration => registration.SupportsConcurrent);
internal IEnumerable<string> NonConcurrentExecutorIds =>
this.Registrations.Values.Where(r => !r.SupportsConcurrent).Select(r => r.Id);
/// <summary>
/// Initializes a new instance of the <see cref="Workflow"/> class with the specified starting executor identifier
/// and input type.
@@ -140,6 +145,23 @@ public class Workflow
private object? _ownerToken;
private bool _ownedAsSubworkflow;
internal void CheckOwnership(object? existingOwnershipSignoff = null)
{
object? maybeOwned = Volatile.Read(ref this._ownerToken);
if (!ReferenceEquals(maybeOwned, existingOwnershipSignoff))
{
throw new InvalidOperationException($"Existing ownership does not match check value. {Summarize(maybeOwned)} vs. {Summarize(existingOwnershipSignoff)}");
}
string Summarize(object? maybeOwnerToken) => maybeOwnerToken switch
{
string s => $"'{s}'",
null => "<null>",
_ => $"{maybeOwnerToken.GetType().Name}@{maybeOwnerToken.GetHashCode()}",
};
}
internal void TakeOwnership(object ownerToken, bool subworkflow = false, object? existingOwnershipSignoff = null)
{
object? maybeToken = Interlocked.CompareExchange(ref this._ownerToken, ownerToken, existingOwnershipSignoff);
@@ -180,19 +202,18 @@ public class Workflow
Justification = "Does not exist in NetFx 4.7.2")]
internal async ValueTask ReleaseOwnershipAsync(object ownerToken)
{
if (this._ownerToken == null)
object? originalToken = Interlocked.CompareExchange(ref this._ownerToken, null, ownerToken);
if (originalToken == null)
{
throw new InvalidOperationException("Attempting to release ownership of a Workflow that is not owned.");
}
if (!ReferenceEquals(this._ownerToken, this._ownerToken))
if (!ReferenceEquals(originalToken, ownerToken))
{
throw new InvalidOperationException("Attempt to release ownership of a Workflow by non-owner.");
}
await this.TryResetExecutorRegistrationsAsync().ConfigureAwait(false);
Interlocked.CompareExchange(ref this._ownerToken, null, ownerToken);
}
}
@@ -17,20 +17,26 @@ internal sealed class WorkflowHostAgent : AIAgent
private readonly Workflow _workflow;
private readonly string? _id;
private readonly CheckpointManager? _checkpointManager;
private readonly IWorkflowExecutionEnvironment _executionEnvironment;
private readonly ConcurrentDictionary<string, string> _assignedRunIds = [];
public WorkflowHostAgent(Workflow<List<ChatMessage>> workflow, string? id = null, string? name = null, CheckpointManager? checkpointManager = null)
public WorkflowHostAgent(Workflow<List<ChatMessage>> workflow, string? id = null, string? name = null, string? description = null, CheckpointManager? checkpointManager = null, IWorkflowExecutionEnvironment? executionEnvironment = null)
{
this._workflow = Throw.IfNull(workflow);
this._executionEnvironment = executionEnvironment ?? (workflow.AllowConcurrent
? InProcessExecution.Concurrent
: InProcessExecution.OffThread);
this._checkpointManager = checkpointManager;
this._id = id;
this.Name = name;
this._checkpointManager = checkpointManager;
this.Description = description;
}
public override string? Name { get; }
public override string Id => this._id ?? base.Id;
public override string? Name { get; }
public override string? Description { get; }
private string GenerateNewId()
{
@@ -44,10 +50,10 @@ internal sealed class WorkflowHostAgent : AIAgent
return result;
}
public override AgentThread GetNewThread() => new WorkflowThread(this._workflow, this.GenerateNewId(), this._checkpointManager);
public override AgentThread GetNewThread() => new WorkflowThread(this._workflow, this.GenerateNewId(), this._executionEnvironment, this._checkpointManager);
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> new WorkflowThread(this._workflow, serializedThread, this._checkpointManager, jsonSerializerOptions);
=> new WorkflowThread(this._workflow, serializedThread, this._executionEnvironment, this._checkpointManager, jsonSerializerOptions);
private async ValueTask<WorkflowThread> UpdateThreadAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, CancellationToken cancellationToken = default)
{
@@ -15,23 +15,45 @@ public static class WorkflowHostingExtensions
/// <summary>
/// Convert a workflow with the appropriate primary input type to an <see cref="AIAgent"/>.
/// </summary>
/// <param name="workflow"></param>
/// <param name="id"></param>
/// <param name="name"></param>
/// <param name="workflow">The workflow to be hosted by the resulting <see cref="AIAgent"/></param>
/// <param name="id">A unique id for the hosting <see cref="AIAgent"/>.</param>
/// <param name="name">A name for the hosting <see cref="AIAgent"/>.</param>
/// <param name="description">A description for the hosting <see cref="AIAgent"/>.</param>
/// <param name="checkpointManager">A <see cref="CheckpointManager"/> to enable persistence of run state.</param>
/// <param name="executionEnvironment">Specify the execution environment to use when running the workflows. See
/// <see cref="InProcessExecution.OffThread"/>, <see cref="InProcessExecution.Concurrent"/> and
/// <see cref="InProcessExecution.Lockstep"/> for the in-process environments.</param>
/// <returns></returns>
public static AIAgent AsAgent(this Workflow<List<ChatMessage>> workflow, string? id = null, string? name = null)
public static AIAgent AsAgent(
this Workflow<List<ChatMessage>> workflow,
string? id = null,
string? name = null,
string? description = null,
CheckpointManager? checkpointManager = null,
IWorkflowExecutionEnvironment? executionEnvironment = null)
{
return new WorkflowHostAgent(workflow, id, name);
return new WorkflowHostAgent(workflow, id, name, description, checkpointManager, executionEnvironment);
}
/// <summary>
/// Convert a workflow with the appropriate primary input type to an <see cref="AIAgent"/>.
/// </summary>
/// <param name="workflow"></param>
/// <param name="id"></param>
/// <param name="name"></param>
/// <param name="workflow">The workflow to be hosted by the resulting <see cref="AIAgent"/></param>
/// <param name="id">A unique id for the hosting <see cref="AIAgent"/>.</param>
/// <param name="name">A name for the hosting <see cref="AIAgent"/>.</param>
/// /// <param name="description">A description for the hosting <see cref="AIAgent"/>.</param>
/// <param name="checkpointManager">A <see cref="CheckpointManager"/> to enable persistence of run state.</param>
/// <param name="executionEnvironment">Specify the execution environment to use when running the workflows. See
/// <see cref="InProcessExecution.OffThread"/>, <see cref="InProcessExecution.Concurrent"/> and
/// <see cref="InProcessExecution.Lockstep"/> for the in-process environments.</param>
/// <returns></returns>
public static async ValueTask<AIAgent> AsAgentAsync(this Workflow workflow, string? id = null, string? name = null)
public static async ValueTask<AIAgent> AsAgentAsync(
this Workflow workflow,
string? id = null,
string? name = null,
string? description = null,
CheckpointManager? checkpointManager = null,
IWorkflowExecutionEnvironment? executionEnvironment = null)
{
Workflow<List<ChatMessage>>? maybeTyped = await workflow.TryPromoteAsync<List<ChatMessage>>()
.ConfigureAwait(false);
@@ -41,7 +63,7 @@ public static class WorkflowHostingExtensions
throw new InvalidOperationException("Cannot host a workflow that does not accept List<ChatMessage> as an input");
}
return maybeTyped.AsAgent(id: id, name: name);
return maybeTyped.AsAgent(id, name, description, checkpointManager, executionEnvironment);
}
internal static FunctionCallContent ToFunctionCall(this ExternalRequest request)
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
namespace Microsoft.Agents.AI.Workflows;
@@ -26,6 +27,24 @@ public sealed class WorkflowOutputEvent : WorkflowEvent
/// <returns>true if the underlying data is assignable to type T; otherwise, false.</returns>
public bool Is<T>() => this.IsType(typeof(T));
/// <summary>
/// Determines whether the underlying data is of the specified type or a derived type, and
/// returns it as that type if it is.
/// </summary>
/// <typeparam name="T">The type to compare with the type of the underlying data.</typeparam>
/// <returns>true if the underlying data is assignable to type T; otherwise, false.</returns>
public bool Is<T>([NotNullWhen(true)] out T? maybeValue)
{
if (this.Data is T value)
{
maybeValue = value;
return true;
}
maybeValue = default;
return false;
}
/// <summary>
/// Determines whether the underlying data is of the specified type or a derived type.
/// </summary>
@@ -15,13 +15,16 @@ namespace Microsoft.Agents.AI.Workflows;
internal sealed class WorkflowThread : AgentThread
{
private readonly Workflow _workflow;
private readonly IWorkflowExecutionEnvironment _executionEnvironment;
private readonly CheckpointManager _checkpointManager;
private readonly InMemoryCheckpointManager? _inMemoryCheckpointManager;
private readonly Workflow _workflow;
public WorkflowThread(Workflow workflow, string runId, CheckpointManager? checkpointManager = null)
public WorkflowThread(Workflow workflow, string runId, IWorkflowExecutionEnvironment executionEnvironment, CheckpointManager? checkpointManager = null)
{
this._workflow = Throw.IfNull(workflow);
this._executionEnvironment = Throw.IfNull(executionEnvironment);
// If the user provided an external checkpoint manager, use that, otherwise rely on an in-memory one.
// TODO: Implement persist-only-last functionality for in-memory checkpoint manager, to avoid unbounded
@@ -32,9 +35,10 @@ internal sealed class WorkflowThread : AgentThread
this.MessageStore = new WorkflowMessageStore();
}
public WorkflowThread(Workflow workflow, JsonElement serializedThread, CheckpointManager? checkpointManager = null, JsonSerializerOptions? jsonSerializerOptions = null)
public WorkflowThread(Workflow workflow, JsonElement serializedThread, IWorkflowExecutionEnvironment executionEnvironment, CheckpointManager? checkpointManager = null, JsonSerializerOptions? jsonSerializerOptions = null)
{
this._workflow = Throw.IfNull(workflow);
this._executionEnvironment = Throw.IfNull(executionEnvironment);
JsonMarshaller marshaller = new(jsonSerializerOptions);
ThreadState threadState = marshaller.Marshal<ThreadState>(serializedThread);
@@ -101,23 +105,25 @@ internal sealed class WorkflowThread : AgentThread
if (this.LastCheckpoint is not null)
{
Checkpointed<StreamingRun> checkpointed =
await InProcessExecution.ResumeStreamAsync(this._workflow,
this.LastCheckpoint,
this._checkpointManager,
this.RunId,
cancellationToken)
.ConfigureAwait(false);
await this._executionEnvironment
.ResumeStreamAsync(this._workflow,
this.LastCheckpoint,
this._checkpointManager,
this.RunId,
cancellationToken)
.ConfigureAwait(false);
await checkpointed.Run.TrySendMessageAsync(messages).ConfigureAwait(false);
return checkpointed;
}
return await InProcessExecution.StreamAsync(this._workflow,
messages,
this._checkpointManager,
this.RunId,
cancellationToken)
.ConfigureAwait(false);
return await this._executionEnvironment
.StreamAsync(this._workflow,
messages,
this._checkpointManager,
this.RunId,
cancellationToken)
.ConfigureAwait(false);
}
internal async
@@ -6,6 +6,7 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.Execution;
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows;
@@ -88,6 +89,9 @@ internal static partial class WorkflowsJsonUtilities
[JsonSerializable(typeof(ExternalResponse))]
[JsonSerializable(typeof(TurnToken))]
// Built-in Executor State Types
[JsonSerializable(typeof(AIAgentHostExecutor))]
// Event Types
//[JsonSerializable(typeof(WorkflowEvent))]
// Currently cannot be serialized because it includes Exceptions.