.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 17:34:17 -04:00
committed by GitHub
Unverified
parent 69e1ab0409
commit 331c750515
67 changed files with 2152 additions and 1347 deletions
@@ -70,7 +70,7 @@ public static class Program
case "groupchat":
await RunWorkflowAsync(
AgentWorkflowBuilder.CreateGroupChatBuilderWith(agents => new AgentWorkflowBuilder.RoundRobinGroupChatManager(agents) { MaximumIterationCount = 5 })
AgentWorkflowBuilder.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 5 })
.AddParticipants(from lang in (string[])["French", "Spanish", "English"] select GetTranslationAgent(lang, client))
.Build(),
[new(ChatRole.User, "Hello, world!")]);
@@ -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.
@@ -57,24 +57,24 @@ public class AgentWorkflowBuilderTests
{
Assert.Throws<ArgumentNullException>("managerFactory", () => AgentWorkflowBuilder.CreateGroupChatBuilderWith(null!));
var groupChat = AgentWorkflowBuilder.CreateGroupChatBuilderWith(_ => new AgentWorkflowBuilder.RoundRobinGroupChatManager([new DoubleEchoAgent("a1")]));
var groupChat = AgentWorkflowBuilder.CreateGroupChatBuilderWith(_ => new RoundRobinGroupChatManager([new DoubleEchoAgent("a1")]));
Assert.NotNull(groupChat);
Assert.Throws<ArgumentNullException>("agents", () => groupChat.AddParticipants(null!));
Assert.Throws<ArgumentNullException>("agents", () => groupChat.AddParticipants([null!]));
Assert.Throws<ArgumentNullException>("agents", () => groupChat.AddParticipants(new DoubleEchoAgent("a1"), null!));
Assert.Throws<ArgumentNullException>("agents", () => new AgentWorkflowBuilder.RoundRobinGroupChatManager(null!));
Assert.Throws<ArgumentNullException>("agents", () => new RoundRobinGroupChatManager(null!));
}
[Fact]
public void GroupChatManager_MaximumIterationCount_Invalid_Throws()
{
var manager = new AgentWorkflowBuilder.RoundRobinGroupChatManager([new DoubleEchoAgent("a1")]);
var manager = new RoundRobinGroupChatManager([new DoubleEchoAgent("a1")]);
const int DefaultMaxIterations = 40;
Assert.Equal(DefaultMaxIterations, manager.MaximumIterationCount);
Assert.Throws<ArgumentOutOfRangeException>("value", () => manager.MaximumIterationCount = 0);
Assert.Throws<ArgumentOutOfRangeException>("value", () => manager.MaximumIterationCount = -1);
Assert.Throws<ArgumentOutOfRangeException>("value", void () => manager.MaximumIterationCount = 0);
Assert.Throws<ArgumentOutOfRangeException>("value", void () => manager.MaximumIterationCount = -1);
Assert.Equal(DefaultMaxIterations, manager.MaximumIterationCount);
manager.MaximumIterationCount = 30;
@@ -344,7 +344,7 @@ public class AgentWorkflowBuilderTests
public async Task BuildGroupChat_AgentsRunInOrderAsync(int maxIterations)
{
const int NumAgents = 3;
var workflow = AgentWorkflowBuilder.CreateGroupChatBuilderWith(agents => new AgentWorkflowBuilder.RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxIterations })
var workflow = AgentWorkflowBuilder.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxIterations })
.AddParticipants(new DoubleEchoAgent("agent1"), new DoubleEchoAgent("agent2"))
.AddParticipants(new DoubleEchoAgent("agent3"))
.Build();
@@ -386,7 +386,7 @@ public class AgentWorkflowBuilderTests
{
StringBuilder sb = new();
await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, input);
await using StreamingRun run = await InProcessExecution.Lockstep.StreamAsync(workflow, input);
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
WorkflowOutputEvent? output = null;
@@ -40,46 +40,12 @@ public class ChatProtocolExecutorTests
}
}
private sealed class TestWorkflowContext : IWorkflowContext
{
public List<object> SentMessages { get; } = [];
public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default) =>
default;
public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default) =>
default;
public ValueTask RequestHaltAsync() =>
default;
public ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default) =>
default;
public ValueTask QueueStateUpdateAsync<T>(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default) =>
default;
public ValueTask<T?> ReadStateAsync<T>(string key, string? scopeName = null, CancellationToken cancellationToken = default) =>
default;
public ValueTask<HashSet<string>> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default) =>
default;
public ValueTask SendMessageAsync(object message, string? targetId = null, CancellationToken cancellationToken = default)
{
this.SentMessages.Add(message);
return default;
}
public IReadOnlyDictionary<string, string>? TraceContext => null;
}
[Fact]
public async Task ChatProtocolExecutor_Handles_ListOfChatMessagesAsync()
{
// Arrange
var executor = new TestChatProtocolExecutor();
var context = new TestWorkflowContext();
var context = new TestWorkflowContext(executor.Id);
List<ChatMessage> messages =
[
@@ -103,7 +69,7 @@ public class ChatProtocolExecutorTests
{
// Arrange
var executor = new TestChatProtocolExecutor();
var context = new TestWorkflowContext();
var context = new TestWorkflowContext(executor.Id);
ChatMessage[] messages =
[
@@ -129,7 +95,7 @@ public class ChatProtocolExecutorTests
{
// Arrange
var executor = new TestChatProtocolExecutor();
var context = new TestWorkflowContext();
var context = new TestWorkflowContext(executor.Id);
var message = new ChatMessage(ChatRole.User, "Single message");
@@ -147,7 +113,7 @@ public class ChatProtocolExecutorTests
public async Task ChatProtocolExecutor_AccumulatesAndClearsMessagesPerTurnAsync()
{
var executor = new TestChatProtocolExecutor();
var context = new TestWorkflowContext();
var context = new TestWorkflowContext(executor.Id);
// Send multiple message batches before taking a turn
await executor.ExecuteAsync(new ChatMessage(ChatRole.User, "Message 1"), new TypeId(typeof(ChatMessage)), context);
@@ -186,7 +152,7 @@ public class ChatProtocolExecutorTests
{
StringMessageChatRole = ChatRole.User
});
var context = new TestWorkflowContext();
var context = new TestWorkflowContext(executor.Id);
await executor.ExecuteAsync("String message", new TypeId(typeof(string)), context);
await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context);
@@ -200,7 +166,7 @@ public class ChatProtocolExecutorTests
public async Task ChatProtocolExecutor_EmptyCollection_HandledCorrectlyAsync()
{
var executor = new TestChatProtocolExecutor();
var context = new TestWorkflowContext();
var context = new TestWorkflowContext(executor.Id);
await executor.ExecuteAsync(new List<ChatMessage>(), new TypeId(typeof(List<ChatMessage>)), context);
await executor.ExecuteAsync(Array.Empty<ChatMessage>(), new TypeId(typeof(ChatMessage[])), context);
@@ -216,7 +182,7 @@ public class ChatProtocolExecutorTests
public async Task ChatProtocolExecutor_RoutesCollectionTypesAsync(Type collectionType)
{
var executor = new TestChatProtocolExecutor();
var context = new TestWorkflowContext();
var context = new TestWorkflowContext(executor.Id);
var sourceMessages = new[] { new ChatMessage(ChatRole.User, "Test message") };
object messagesToSend = collectionType == typeof(List<ChatMessage>) ? sourceMessages.ToList() : sourceMessages;
@@ -232,7 +198,7 @@ public class ChatProtocolExecutorTests
public async Task ChatProtocolExecutor_MultipleTurns_EachTurnProcessesSeparatelyAsync()
{
var executor = new TestChatProtocolExecutor();
var context = new TestWorkflowContext();
var context = new TestWorkflowContext(executor.Id);
await executor.ExecuteAsync(new List<ChatMessage> { new(ChatRole.User, "Turn 1") }, new TypeId(typeof(List<ChatMessage>)), context);
await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context);
@@ -252,7 +218,7 @@ public class ChatProtocolExecutorTests
public async Task ChatProtocolExecutor_InitialWorkflowMessages_RoutedCorrectlyAsync()
{
var executor = new TestChatProtocolExecutor();
var context = new TestWorkflowContext();
var context = new TestWorkflowContext(executor.Id);
List<ChatMessage> initialMessages = [new ChatMessage(ChatRole.User, "Kick off the workflow")];
@@ -1,20 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Agents.AI.Workflows.InProc;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
internal static class ExecutionExtensions
{
public static InProcessExecutionEnvironment GetEnvironment(this ExecutionMode executionMode)
public static IWorkflowExecutionEnvironment ToWorkflowExecutionEnvironment(this ExecutionEnvironment environment)
{
return executionMode switch
return environment switch
{
ExecutionMode.OffThread => InProcessExecution.OffThread,
ExecutionMode.Lockstep => InProcessExecution.Lockstep,
ExecutionMode.Subworkflow => throw new NotSupportedException(),
_ => throw new InvalidOperationException($"Unknown execution mode {executionMode}")
ExecutionEnvironment.InProcess_OffThread => InProcessExecution.OffThread,
ExecutionEnvironment.InProcess_Lockstep => InProcessExecution.Lockstep,
ExecutionEnvironment.InProcess_Concurrent => InProcessExecution.Concurrent,
_ => throw new InvalidOperationException($"Unknown execution environment {environment}")
};
}
}
@@ -1,7 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
<NoWarn>$(NoWarn);MEAI001</NoWarn>
</PropertyGroup>
<ItemGroup>
@@ -5,9 +5,7 @@ using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.InProc;
using Microsoft.Agents.AI.Workflows.Reflection;
using Microsoft.Agents.AI.Workflows.UnitTests;
namespace Microsoft.Agents.AI.Workflows.Sample;
@@ -27,11 +25,9 @@ internal static class Step1EntryPoint
}
}
public static async ValueTask RunAsync(TextWriter writer, ExecutionMode executionMode)
public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment)
{
InProcessExecutionEnvironment env = executionMode.GetEnvironment();
StreamingRun run = await env.StreamAsync(WorkflowInstance, "Hello, World!").ConfigureAwait(false);
StreamingRun run = await environment.StreamAsync(WorkflowInstance, "Hello, World!").ConfigureAwait(false);
await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false))
{
@@ -43,13 +39,13 @@ internal static class Step1EntryPoint
}
}
internal sealed class UppercaseExecutor() : ReflectingExecutor<UppercaseExecutor>("UppercaseExecutor"), IMessageHandler<string, string>
internal sealed class UppercaseExecutor() : ReflectingExecutor<UppercaseExecutor>("UppercaseExecutor", declareCrossRunShareable: true), IMessageHandler<string, string>
{
public async ValueTask<string> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) =>
message.ToUpperInvariant();
}
internal sealed class ReverseTextExecutor() : ReflectingExecutor<ReverseTextExecutor>("ReverseTextExecutor"), IMessageHandler<string, string>
internal sealed class ReverseTextExecutor() : ReflectingExecutor<ReverseTextExecutor>("ReverseTextExecutor", declareCrossRunShareable: true), IMessageHandler<string, string>
{
public async ValueTask<string> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
@@ -2,18 +2,15 @@
using System.IO;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.InProc;
using Microsoft.Agents.AI.Workflows.UnitTests;
using static Microsoft.Agents.AI.Workflows.Sample.Step1EntryPoint;
namespace Microsoft.Agents.AI.Workflows.Sample;
internal static class Step1aEntryPoint
{
public static async ValueTask RunAsync(TextWriter writer, ExecutionMode executionMode)
public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment)
{
InProcessExecutionEnvironment env = executionMode.GetEnvironment();
Run run = await env.RunAsync(WorkflowInstance, "Hello, World!").ConfigureAwait(false);
Run run = await environment.RunAsync(WorkflowInstance, "Hello, World!").ConfigureAwait(false);
Assert.Equal(RunStatus.Idle, await run.GetStatusAsync());
@@ -5,9 +5,7 @@ using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.InProc;
using Microsoft.Agents.AI.Workflows.Reflection;
using Microsoft.Agents.AI.Workflows.UnitTests;
namespace Microsoft.Agents.AI.Workflows.Sample;
@@ -31,10 +29,9 @@ internal static class Step2EntryPoint
}
}
public static async ValueTask<string> RunAsync(TextWriter writer, ExecutionMode executionMode, string input = "This is a spam message.")
public static async ValueTask<string> RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment, string input = "This is a spam message.")
{
InProcessExecutionEnvironment env = executionMode.GetEnvironment();
StreamingRun handle = await env.StreamAsync(WorkflowInstance, input).ConfigureAwait(false);
StreamingRun handle = await environment.StreamAsync(WorkflowInstance, input).ConfigureAwait(false);
await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false))
{
switch (evt)
@@ -55,13 +52,13 @@ internal static class Step2EntryPoint
}
internal sealed class DetectSpamExecutor(string id, params string[] spamKeywords) :
ReflectingExecutor<DetectSpamExecutor>(id), IMessageHandler<string, bool>
ReflectingExecutor<DetectSpamExecutor>(id, declareCrossRunShareable: true), IMessageHandler<string, bool>
{
public async ValueTask<bool> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) =>
spamKeywords.Any(keyword => message.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) >= 0);
}
internal sealed class RespondToMessageExecutor(string id) : ReflectingExecutor<RespondToMessageExecutor>(id), IMessageHandler<bool>
internal sealed class RespondToMessageExecutor(string id) : ReflectingExecutor<RespondToMessageExecutor>(id, declareCrossRunShareable: true), IMessageHandler<bool>
{
public const string ActionResult = "Message processed successfully.";
@@ -80,7 +77,7 @@ internal sealed class RespondToMessageExecutor(string id) : ReflectingExecutor<R
}
}
internal sealed class RemoveSpamExecutor(string id) : ReflectingExecutor<RemoveSpamExecutor>(id), IMessageHandler<bool>
internal sealed class RemoveSpamExecutor(string id) : ReflectingExecutor<RemoveSpamExecutor>(id, declareCrossRunShareable: true), IMessageHandler<bool>
{
public const string ActionResult = "Spam message removed.";
@@ -4,9 +4,7 @@ using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.InProc;
using Microsoft.Agents.AI.Workflows.Reflection;
using Microsoft.Agents.AI.Workflows.UnitTests;
namespace Microsoft.Agents.AI.Workflows.Sample;
@@ -27,10 +25,9 @@ internal static class Step3EntryPoint
}
}
public static async ValueTask<string> RunAsync(TextWriter writer, ExecutionMode executionMode)
public static async ValueTask<string> RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment)
{
InProcessExecutionEnvironment env = executionMode.GetEnvironment();
StreamingRun run = await env.StreamAsync(WorkflowInstance, NumberSignal.Init).ConfigureAwait(false);
StreamingRun run = await environment.StreamAsync(WorkflowInstance, NumberSignal.Init).ConfigureAwait(false);
await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false))
{
@@ -51,6 +48,16 @@ internal static class Step3EntryPoint
}
}
internal sealed record TryCount(int Tries);
internal sealed record NumberBounds(int LowerBound, int UpperBound)
{
public int CurrGuess => (this.LowerBound + this.UpperBound) / 2;
public NumberBounds ForAboveHint() => this with { UpperBound = this.CurrGuess - 1 };
public NumberBounds ForBelowHint() => this with { LowerBound = this.CurrGuess + 1 };
}
internal enum NumberSignal
{
Init,
@@ -61,74 +68,65 @@ internal enum NumberSignal
internal sealed class GuessNumberExecutor : ReflectingExecutor<GuessNumberExecutor>, IMessageHandler<NumberSignal, int>
{
public int LowerBound { get; private set; }
public int UpperBound { get; private set; }
private readonly int _initialLowerBound;
private readonly int _initialUpperBound;
public GuessNumberExecutor(string id, int lowerBound, int upperBound) : base(id, new ExecutorOptions { AutoYieldOutputHandlerResultObject = false })
public GuessNumberExecutor(string id, int lowerBound, int upperBound) : base(id, new ExecutorOptions { AutoYieldOutputHandlerResultObject = false }, declareCrossRunShareable: true)
{
this.LowerBound = lowerBound;
this.UpperBound = upperBound;
if (lowerBound >= upperBound)
{
throw new ArgumentOutOfRangeException(nameof(lowerBound), "Lower bound must be less than upper bound.");
}
this._initialLowerBound = lowerBound;
this._initialUpperBound = upperBound;
}
private int NextGuess => (this.LowerBound + this.UpperBound) / 2;
private int _currGuess = -1;
public async ValueTask<int> HandleAsync(NumberSignal message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
NumberBounds bounds = await context.ReadStateAsync<NumberBounds>(nameof(NumberBounds), cancellationToken: cancellationToken)
.ConfigureAwait(false)
?? new NumberBounds(this._initialLowerBound, this._initialUpperBound);
switch (message)
{
case NumberSignal.Matched:
await context.YieldOutputAsync($"Guessed the number: {this._currGuess}", cancellationToken)
await context.YieldOutputAsync($"Guessed the number: {bounds.CurrGuess}", cancellationToken)
.ConfigureAwait(false);
break;
case NumberSignal.Above:
this.UpperBound = this._currGuess - 1;
bounds = bounds.ForAboveHint();
break;
case NumberSignal.Below:
this.LowerBound = this._currGuess + 1;
bounds = bounds.ForBelowHint();
break;
}
this._currGuess = this.NextGuess;
return this._currGuess;
await context.QueueStateUpdateAsync(nameof(NumberBounds), bounds, cancellationToken: cancellationToken).ConfigureAwait(false);
return bounds.CurrGuess;
}
}
internal sealed class JudgeExecutor : ReflectingExecutor<JudgeExecutor>, IMessageHandler<int, NumberSignal>, IResettableExecutor
internal sealed class JudgeExecutor : ReflectingExecutor<JudgeExecutor>, IMessageHandler<int, NumberSignal>
{
private readonly int _targetNumber;
internal int? Tries { get; private set; }
public JudgeExecutor(string id, int targetNumber) : base(id)
public JudgeExecutor(string id, int targetNumber) : base(id, declareCrossRunShareable: true)
{
this._targetNumber = targetNumber;
}
public async ValueTask<NumberSignal> HandleAsync(int message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
this.Tries = this.Tries is int tries ? tries + 1 : 1;
// This works properly because the default when unset is 0, and we increment before use.
int tries = await context.ReadStateAsync<int>("TryCount", cancellationToken: cancellationToken).ConfigureAwait(false) + 1;
await context.YieldOutputAsync(new TryCount(tries), cancellationToken);
return
message == this._targetNumber ? NumberSignal.Matched :
message < this._targetNumber ? NumberSignal.Below :
NumberSignal.Above;
}
protected internal override ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
return context.QueueStateUpdateAsync("TryCount", this.Tries, cancellationToken: cancellationToken);
}
protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
this.Tries = await context.ReadStateAsync<int?>("TryCount", cancellationToken: cancellationToken).ConfigureAwait(false) ?? 0;
}
public ValueTask ResetAsync()
{
this.Tries = null;
return default;
}
}
@@ -4,8 +4,6 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.InProc;
using Microsoft.Agents.AI.Workflows.UnitTests;
namespace Microsoft.Agents.AI.Workflows.Sample;
@@ -39,14 +37,13 @@ internal static class Step4EntryPoint
}
}
public static async ValueTask<string> RunAsync(TextWriter writer, Func<string, int> userGuessCallback, ExecutionMode executionMode)
public static async ValueTask<string> RunAsync(TextWriter writer, Func<string, int> userGuessCallback, IWorkflowExecutionEnvironment environment)
{
NumberSignal signal = NumberSignal.Init;
string? prompt = UpdatePrompt(null, signal);
Workflow workflow = WorkflowInstance;
InProcessExecutionEnvironment env = executionMode.GetEnvironment();
StreamingRun handle = await env.StreamAsync(workflow, NumberSignal.Init).ConfigureAwait(false);
StreamingRun handle = await environment.StreamAsync(workflow, NumberSignal.Init).ConfigureAwait(false);
List<ExternalRequest> requests = [];
await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false))
@@ -57,13 +54,15 @@ internal static class Step4EntryPoint
switch (outputEvent.SourceId)
{
case JudgeId:
if (!outputEvent.Is<NumberSignal>())
if (outputEvent.Is(out NumberSignal newSignal))
{
prompt = UpdatePrompt(prompt, signal = newSignal);
}
else if (!outputEvent.Is<TryCount>())
{
throw new InvalidOperationException($"Unexpected output type {outputEvent.Data!.GetType()}");
}
signal = outputEvent.As<NumberSignal?>()!.Value;
prompt = UpdatePrompt(prompt, signal);
break;
}
@@ -6,14 +6,12 @@ using System.IO;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.InProc;
using Microsoft.Agents.AI.Workflows.UnitTests;
namespace Microsoft.Agents.AI.Workflows.Sample;
internal static class Step5EntryPoint
{
public static async ValueTask<string> RunAsync(TextWriter writer, Func<string, int> userGuessCallback, ExecutionMode executionMode, bool rehydrateToRestore = false, CheckpointManager? checkpointManager = null)
public static async ValueTask<string> RunAsync(TextWriter writer, Func<string, int> userGuessCallback, IWorkflowExecutionEnvironment environment, bool rehydrateToRestore = false, CheckpointManager? checkpointManager = null)
{
Dictionary<CheckpointInfo, (NumberSignal signal, string? prompt)> checkpointedOutputs = [];
@@ -24,10 +22,9 @@ internal static class Step5EntryPoint
Workflow workflow = Step4EntryPoint.CreateWorkflowInstance(out JudgeExecutor judge);
InProcessExecutionEnvironment env = executionMode.GetEnvironment();
Checkpointed<StreamingRun> checkpointed =
await env.StreamAsync(workflow, NumberSignal.Init, checkpointManager)
.ConfigureAwait(false);
await environment.StreamAsync(workflow, NumberSignal.Init, checkpointManager)
.ConfigureAwait(false);
List<CheckpointInfo> checkpoints = [];
CancellationTokenSource cancellationSource = new();
@@ -37,7 +34,6 @@ internal static class Step5EntryPoint
result.Should().BeNull();
checkpoints.Should().HaveCount(6, "we should have two checkpoints, one for each step");
judge.Tries.Should().Be(2);
CheckpointInfo targetCheckpoint = checkpoints[2];
@@ -46,8 +42,8 @@ internal static class Step5EntryPoint
{
await handle.DisposeAsync().ConfigureAwait(false);
checkpointed = await env.ResumeStreamAsync(workflow, targetCheckpoint, checkpointManager, runId: handle.RunId, cancellationToken: CancellationToken.None)
.ConfigureAwait(false);
checkpointed = await environment.ResumeStreamAsync(workflow, targetCheckpoint, checkpointManager, runId: handle.RunId, cancellationToken: CancellationToken.None)
.ConfigureAwait(false);
handle = checkpointed.Run;
}
else
@@ -57,8 +53,6 @@ internal static class Step5EntryPoint
(signal, prompt) = checkpointedOutputs[targetCheckpoint];
judge.Tries.Should().Be(1);
cancellationSource.Dispose();
cancellationSource = new();
@@ -66,7 +60,11 @@ internal static class Step5EntryPoint
result = await RunStreamToHaltOrMaxStepAsync().ConfigureAwait(false);
result.Should().NotBeNull();
checkpoints.Should().HaveCount(6);
// Depending on the timing of the response with respect to the underlying workflow
// we may end up with an extra superstep in between.
checkpoints.Should().HaveCountGreaterThanOrEqualTo(6)
.And.HaveCountLessThanOrEqualTo(7);
cancellationSource.Dispose();
@@ -84,13 +82,16 @@ internal static class Step5EntryPoint
switch (outputEvent.SourceId)
{
case Step4EntryPoint.JudgeId:
if (!outputEvent.Is<NumberSignal>())
if (outputEvent.Is(out NumberSignal newSignal))
{
prompt = Step4EntryPoint.UpdatePrompt(prompt, signal = newSignal);
}
// TODO: We should make some well-defined way to avoid this kind of
// if/elseif chain, because .Is() chains are slow
else if (!outputEvent.Is<TryCount>())
{
throw new InvalidOperationException($"Unexpected output type {outputEvent.Data!.GetType()}");
}
signal = outputEvent.As<NumberSignal?>()!.Value;
prompt = Step4EntryPoint.UpdatePrompt(null, signal);
break;
}
@@ -10,8 +10,6 @@ using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.InProc;
using Microsoft.Agents.AI.Workflows.UnitTests;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Sample;
@@ -20,16 +18,15 @@ internal static class Step6EntryPoint
{
public static Workflow CreateWorkflow(int maxTurns) =>
AgentWorkflowBuilder
.CreateGroupChatBuilderWith(agents => new AgentWorkflowBuilder.RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxTurns })
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxTurns })
.AddParticipants(new HelloAgent(), new EchoAgent())
.Build();
public static async ValueTask RunAsync(TextWriter writer, ExecutionMode executionMode, int maxSteps = 2)
public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment, int maxSteps = 2)
{
Workflow workflow = CreateWorkflow(maxSteps);
InProcessExecutionEnvironment env = executionMode.GetEnvironment();
StreamingRun run = await env.StreamAsync(workflow, Array.Empty<ChatMessage>())
StreamingRun run = await environment.StreamAsync(workflow, Array.Empty<ChatMessage>())
.ConfigureAwait(false);
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
@@ -7,15 +7,13 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.InProc;
using Microsoft.Agents.AI.Workflows.UnitTests;
namespace Microsoft.Agents.AI.Workflows.Sample;
internal sealed record class TextProcessingRequest(string Text, string TaskId);
internal sealed record class TextProcessingResult(string TaskId, string Text, int WordCount, int ChatCount);
internal sealed class AllTasksCompletedEvent(IEnumerable<TextProcessingResult> results) : WorkflowEvent(results);
//internal sealed class AllTasksCompletedEvent(IEnumerable<TextProcessingResult> results) : WorkflowEvent(results);
internal static class Step8EntryPoint
{
@@ -28,31 +26,39 @@ internal static class Step8EntryPoint
" Spaces around text ",
];
public static async ValueTask<List<TextProcessingResult>> RunAsync(TextWriter writer, ExecutionMode executionMode, List<string> textsToProcess)
public static async ValueTask<List<TextProcessingResult>> RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment, List<string> textsToProcess)
{
Func<TextProcessingRequest, IWorkflowContext, CancellationToken, ValueTask> processTextAsyncFunc = ProcessTextAsync;
ExecutorIsh processText = processTextAsyncFunc.AsExecutor("TextProcessor");
ExecutorIsh processText = processTextAsyncFunc.AsExecutor("TextProcessor", threadsafe: true);
Workflow subWorkflow = new WorkflowBuilder(processText).WithOutputFrom(processText).Build();
ExecutorIsh textProcessor = subWorkflow.ConfigureSubWorkflow("TextProcessor");
TextProcessingOrchestrator orchestrator = new();
Func<string, string, ValueTask<Executor>> createOrchestrator = (id, _) => new(new TextProcessingOrchestrator(id));
var orchestrator = createOrchestrator.ConfigureFactory();
Workflow workflow = new WorkflowBuilder(orchestrator)
.AddEdge(orchestrator, textProcessor)
.AddEdge(textProcessor, orchestrator)
.WithOutputFrom(orchestrator)
.Build();
InProcessExecutionEnvironment env = executionMode.GetEnvironment();
Run workflowRun = await env.RunAsync(workflow, textsToProcess);
Run workflowRun = await environment.RunAsync(workflow, textsToProcess);
RunStatus status = await workflowRun.GetStatusAsync();
status.Should().Be(RunStatus.Idle);
List<TextProcessingResult> results = orchestrator.Results;
WorkflowOutputEvent? maybeOutput = workflowRun.OutgoingEvents.OfType<WorkflowOutputEvent>()
.SingleOrDefault();
maybeOutput.Should().NotBeNull("the workflow should have produced an output event");
List<TextProcessingResult>? maybeResults = maybeOutput.As<List<TextProcessingResult>>();
maybeResults.Should().NotBeNull("the output event should contain the results");
List<TextProcessingResult> results = maybeResults;
results.Sort((left, right) => StringComparer.Ordinal.Compare(left.TaskId, right.TaskId));
// This is a placeholder for the entry point of Step 8.
return results;
}
@@ -70,10 +76,19 @@ internal static class Step8EntryPoint
return context.YieldOutputAsync(new TextProcessingResult(request.TaskId, request.Text, wordCount, charCount), cancellationToken);
}
private sealed class TextProcessingOrchestrator() : Executor("TextOrchestrator")
private sealed class TextProcessingOrchestrator(string id)
: StatefulExecutor<TextProcessingOrchestrator.State>(id, () => new(), declareCrossRunShareable: false)
{
public List<TextProcessingResult> Results { get; } = new();
public HashSet<string> PendingTaskIds { get; } = new();
internal sealed class State
{
public List<TextProcessingResult> Results { get; } = new();
public HashSet<string> PendingTaskIds { get; } = new();
public bool IsComplete => this.PendingTaskIds.Count == 0;
public void AddPending(string taskId) => this.PendingTaskIds.Add(taskId);
public bool CompletePending(string taskId) => this.PendingTaskIds.Remove(taskId);
}
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
@@ -81,28 +96,40 @@ internal static class Step8EntryPoint
.AddHandler<TextProcessingResult>(this.CollectResultAsync);
}
private async ValueTask StartProcessingAsync(List<string> texts, IWorkflowContext context)
private async ValueTask StartProcessingAsync(List<string> texts, IWorkflowContext context, CancellationToken cancellationToken)
{
foreach (TextProcessingRequest request in texts.Select((string value, int index) => new TextProcessingRequest(Text: value, TaskId: $"Task{index}")))
await this.InvokeWithStateAsync(QueueProcessingTasksAsync, context, cancellationToken: cancellationToken);
async ValueTask<State?> QueueProcessingTasksAsync(State state, IWorkflowContext context, CancellationToken cancellationToken)
{
this.PendingTaskIds.Add(request.TaskId);
await context.SendMessageAsync(request).ConfigureAwait(false);
foreach (TextProcessingRequest request in texts.Select((string value, int index) => new TextProcessingRequest(Text: value, TaskId: $"Task{index}")))
{
state.PendingTaskIds.Add(request.TaskId);
await context.SendMessageAsync(request, cancellationToken: cancellationToken).ConfigureAwait(false);
}
return state;
}
}
private ValueTask CollectResultAsync(TextProcessingResult result, IWorkflowContext context)
private async ValueTask CollectResultAsync(TextProcessingResult result, IWorkflowContext context, CancellationToken cancellationToken = default)
{
if (this.PendingTaskIds.Remove(result.TaskId))
{
this.Results.Add(result);
}
await this.InvokeWithStateAsync(CollectResultAndCheckCompletionAsync, context, cancellationToken: cancellationToken);
if (this.PendingTaskIds.Count == 0)
async ValueTask<State?> CollectResultAndCheckCompletionAsync(State state, IWorkflowContext context, CancellationToken cancellationToken)
{
return context.AddEventAsync(new AllTasksCompletedEvent(this.Results));
}
if (state.PendingTaskIds.Remove(result.TaskId))
{
state.Results.Add(result);
}
return default;
if (state.PendingTaskIds.Count == 0)
{
await context.YieldOutputAsync(state.Results, cancellationToken).ConfigureAwait(false);
}
return state;
}
}
}
}
@@ -7,8 +7,6 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.InProc;
using Microsoft.Agents.AI.Workflows.UnitTests;
namespace Microsoft.Agents.AI.Workflows.Sample;
@@ -16,13 +14,26 @@ internal sealed record class UserRequest(string RequestType, string Type, int Am
{
internal static int RequestCount;
public static string CreateId() => Interlocked.Increment(ref RequestCount).ToString();
public static string CreateId()
{
string result = Interlocked.Increment(ref RequestCount).ToString();
Console.Error.WriteLine($"Got Id: {result}");
return result;
}
public static UserRequest CreateResourceRequest(string resourceType = "cpu", int amount = 1, string priority = "normal") =>
new("resource", resourceType, amount, Priority: priority, Id: CreateId());
public static UserRequest CreateResourceRequest(string resourceType = "cpu", int amount = 1, string priority = "normal")
{
UserRequest request = new("resource", resourceType, amount, Priority: priority, Id: CreateId());
Console.Error.WriteLine($"\t{request}");
return request;
}
public static UserRequest CreatePolicyCheckRequest(string resourceType = "cpu", int amount = 1, string policyType = "quota") =>
new("policy", resourceType, amount, PolicyType: policyType, Id: CreateId());
public static UserRequest CreatePolicyCheckRequest(string resourceType = "cpu", int amount = 1, string policyType = "quota")
{
UserRequest request = new("policy", resourceType, amount, PolicyType: policyType, Id: CreateId());
Console.Error.WriteLine($"\t{request}");
return request;
}
public ResourceResponse CreateResourceResponse(int allocated, string source)
=> new(this.Id, this.Type, allocated, source);
@@ -155,7 +166,7 @@ internal static class Step9EntryPoint
public static UserRequest[] RequestsToProcess => [
ResourceHitRequest1,
PolicyHitRequest1,
ResourceHitRequest1,
ResourceHitRequest2,
PolicyMissRequest1, // miss
ResourceMissRequest, // miss
PolicyHitRequest2,
@@ -172,13 +183,12 @@ internal static class Step9EntryPoint
.Select(request => Part2FinishedResponses[request.Id])
.OrderBy(request => request.Id)];
public static async ValueTask<List<RequestFinished>> RunAsync(TextWriter writer, ExecutionMode executionMode)
public static async ValueTask<List<RequestFinished>> RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment)
{
RunStatus runStatus;
List<RequestFinished> results = [];
InProcessExecutionEnvironment env = executionMode.GetEnvironment();
Run workflowRun = await env.RunAsync(WorkflowInstance, RequestsToProcess.ToList());
Run workflowRun = await environment.RunAsync(WorkflowInstance, RequestsToProcess.ToList());
RunStatus part1Status = ExpectedResponsesPart2.Length > 0 ? RunStatus.PendingRequests : RunStatus.Idle;
runStatus = await workflowRun.GetStatusAsync();
@@ -205,6 +215,11 @@ internal static class Step9EntryPoint
policyRequests.Add(requestInfoEvent.Request);
}
}
else if (evt is WorkflowErrorEvent error)
{
Assert.Fail(((Exception)error.Data!).ToString());
Console.Error.WriteLine(error.Data);
}
}
finishedRequests.Sort((left, right) => StringComparer.Ordinal.Compare(left.Id, right.Id));
@@ -260,7 +275,7 @@ internal static class Step9EntryPoint
}
}
internal sealed class ResourceRequestor() : Executor(nameof(ResourceRequestor))
internal sealed class ResourceRequestor() : Executor(nameof(ResourceRequestor), declareCrossRunShareable: true)
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
@@ -304,17 +319,18 @@ internal sealed class ResourceRequestor() : Executor(nameof(ResourceRequestor))
await context.YieldOutputAsync(new RequestFinished(response.Id, RequestType: "policy", PolicyResponse: response));
}
}
internal sealed class ResourceCache() : Executor(nameof(ResourceCache))
internal sealed class ResourceCache()
: StatefulExecutor<Dictionary<string, int>>(nameof(ResourceCache),
InitializeResourceCache,
declareCrossRunShareable: true)
{
private readonly Dictionary<string, int> _availableResources = new()
{
["cpu"] = 10,
["memory"] = 50,
["disk"] = 100,
};
internal List<ResourceResponse> Responses { get; } = [];
private static Dictionary<string, int> InitializeResourceCache()
=> new()
{
["cpu"] = 10,
["memory"] = 50,
["disk"] = 100,
};
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
@@ -324,45 +340,60 @@ internal sealed class ResourceCache() : Executor(nameof(ResourceCache))
.AddHandler<ExternalResponse>(this.CollectResultAsync);
}
private async ValueTask UnwrapAndHandleRequestAsync(ExternalRequest request, IWorkflowContext context)
private async ValueTask UnwrapAndHandleRequestAsync(ExternalRequest request, IWorkflowContext context, CancellationToken cancellationToken = default)
{
if (request.DataIs(out ResourceRequest? resourceRequest))
{
ResourceResponse? response = await this.TryHandleResourceRequestAsync(resourceRequest, context)
ResourceResponse? response = await this.TryHandleResourceRequestAsync(resourceRequest, context, cancellationToken)
.ConfigureAwait(false);
if (response != null)
{
await context.SendMessageAsync(request.CreateResponse(response)).ConfigureAwait(false);
await context.SendMessageAsync(request.CreateResponse(response), cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
// Cache does not have enough resources, forward the request to the external system
await context.SendMessageAsync(request).ConfigureAwait(false);
await context.SendMessageAsync(request, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
}
private async ValueTask<ResourceResponse?> TryHandleResourceRequestAsync(ResourceRequest request, IWorkflowContext context)
private async ValueTask<ResourceResponse?> TryHandleResourceRequestAsync(ResourceRequest request, IWorkflowContext context, CancellationToken cancellationToken = default)
{
if (this._availableResources.TryGetValue(request.ResourceType, out int available) && available >= request.Amount)
Console.Error.WriteLine($"Handling Resource Request {request.Id}");
Dictionary<string, int> availableResources = await this.ReadStateAsync(context, cancellationToken: cancellationToken)
.ConfigureAwait(false);
Console.Error.WriteLine($"Available Resources: {availableResources}");
try
{
// Cache has enough resources, allocate from cache
this._availableResources[request.ResourceType] -= request.Amount;
ResourceResponse resourceResponse = new(request.Id, request.ResourceType, request.Amount, Source: "cache");
this.Responses.Add(resourceResponse);
return resourceResponse;
if (availableResources.TryGetValue(request.ResourceType, out int available) && available >= request.Amount)
{
// Cache has enough resources, allocate from cache
availableResources[request.ResourceType] -= request.Amount;
Console.Error.WriteLine($"Handled Resource Request {request.Id}");
return new(request.Id, request.ResourceType, request.Amount, Source: "cache");
}
}
finally
{
await this.QueueStateUpdateAsync(availableResources, context, cancellationToken)
.ConfigureAwait(false);
}
Console.Error.WriteLine($"Could not handle Resource Request {request.Id}");
return null;
}
private ValueTask CollectResultAsync(ExternalResponse response, IWorkflowContext context)
{
if (response.DataIs(out ResourceResponse? resourceResponse))
if (response.DataIs<ResourceResponse>())
{
// Normally we'd update the cache according to whatever logic we want here.
this.Responses.Add(resourceResponse);
return context.SendMessageAsync(response);
}
@@ -370,16 +401,18 @@ internal sealed class ResourceCache() : Executor(nameof(ResourceCache))
}
}
internal sealed class QuotaPolicyEngine() : Executor(nameof(QuotaPolicyEngine))
internal sealed class QuotaPolicyEngine()
: StatefulExecutor<Dictionary<string, int>>(nameof(QuotaPolicyEngine),
InitializePolicyQuotas,
declareCrossRunShareable: true)
{
private readonly Dictionary<string, int> _quotas = new()
{
["cpu"] = 5,
["memory"] = 20,
["disk"] = 1000,
};
internal List<PolicyResponse> Responses { get; } = [];
private static Dictionary<string, int> InitializePolicyQuotas()
=> new()
{
["cpu"] = 5,
["memory"] = 20,
["disk"] = 1000,
};
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
@@ -406,25 +439,39 @@ internal sealed class QuotaPolicyEngine() : Executor(nameof(QuotaPolicyEngine))
}
}
private async ValueTask<PolicyResponse?> TryHandlePolicyCheckRequestAsync(PolicyCheckRequest request, IWorkflowContext context)
private async ValueTask<PolicyResponse?> TryHandlePolicyCheckRequestAsync(PolicyCheckRequest request, IWorkflowContext context, CancellationToken cancellationToken = default)
{
if (request.PolicyType == "quota" &&
this._quotas.TryGetValue(request.ResourceType, out int quota) &&
request.Amount <= quota)
Console.Error.WriteLine($"Handling Policy Request {request.Id}");
Dictionary<string, int> quotas = await this.ReadStateAsync(context, cancellationToken: cancellationToken)
.ConfigureAwait(false);
Console.Error.WriteLine($"Policy Quotas: {quotas}");
try
{
PolicyResponse policyResponse = new(request.Id, Approved: true, Reason: $"Within quota ({quota})");
this.Responses.Add(policyResponse);
if (request.PolicyType == "quota" &&
quotas.TryGetValue(request.ResourceType, out int quota) &&
request.Amount <= quota)
{
Console.Error.WriteLine($"Handled Policy Request {request.Id}");
return policyResponse;
return new(request.Id, Approved: true, Reason: $"Within quota ({quota})");
}
Console.Error.WriteLine($"Could not handle Policy Request {request.Id}");
return null;
}
finally
{
await this.QueueStateUpdateAsync(quotas, context, cancellationToken).ConfigureAwait(false);
}
return null;
}
private ValueTask CollectAndForwardAsync(ExternalResponse response, IWorkflowContext context)
{
if (response.DataIs(out PolicyResponse? policyResponse))
if (response.DataIs<PolicyResponse>())
{
this.Responses.Add(policyResponse);
return context.SendMessageAsync(response);
}
@@ -432,11 +479,9 @@ internal sealed class QuotaPolicyEngine() : Executor(nameof(QuotaPolicyEngine))
}
}
internal sealed class Coordinator() : Executor(nameof(Coordinator))
internal sealed class Coordinator() : Executor(nameof(Coordinator), declareCrossRunShareable: true)
{
private int _inflightRequests;
internal List<RequestFinished> Results { get; } = [];
private const string StateKey = nameof(StateKey);
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
@@ -447,24 +492,34 @@ internal sealed class Coordinator() : Executor(nameof(Coordinator))
// For some reason, using a lambda here causes the analyzer to generate a spurious
// VSTHRD110: "Observe the awaitable result of this method call by awaiting it, assigning
// to a variable, or passing it to another method"
ValueTask InvokeStartAsync(UserRequest request, IWorkflowContext context)
=> this.StartAsync([request], context);
ValueTask InvokeStartAsync(UserRequest request, IWorkflowContext context, CancellationToken cancellationToken)
=> this.StartAsync([request], context, cancellationToken);
}
private ValueTask HandleFinishedRequestAsync(RequestFinished finished, IWorkflowContext context)
private ValueTask HandleFinishedRequestAsync(RequestFinished finished, IWorkflowContext context, CancellationToken cancellationToken)
{
this.Results.Add(finished);
Interlocked.Decrement(ref this._inflightRequests);
return context.InvokeWithStateAsync<int>(CountFinishedRequestAndYieldResultAsync, StateKey, cancellationToken: cancellationToken);
return context.YieldOutputAsync(finished);
}
private async ValueTask StartAsync(List<UserRequest> request, IWorkflowContext context)
{
Interlocked.Add(ref this._inflightRequests, request.Count);
foreach (UserRequest req in request)
async ValueTask<int> CountFinishedRequestAndYieldResultAsync(int state, IWorkflowContext context, CancellationToken cancellationToken)
{
await context.SendMessageAsync(req).ConfigureAwait(false);
await context.YieldOutputAsync(finished, cancellationToken).ConfigureAwait(false);
return state - 1;
}
}
private ValueTask StartAsync(List<UserRequest> requests, IWorkflowContext context, CancellationToken cancellationToken)
{
return context.InvokeWithStateAsync<int>(CountFinishedRequestAndYieldResultAsync, StateKey, cancellationToken: cancellationToken);
async ValueTask<int> CountFinishedRequestAndYieldResultAsync(int state, IWorkflowContext context, CancellationToken cancellationToken)
{
foreach (UserRequest req in requests)
{
await context.SendMessageAsync(req, cancellationToken: cancellationToken).ConfigureAwait(false);
}
return state + requests.Count;
}
}
}
@@ -11,16 +11,24 @@ using Microsoft.Agents.AI.Workflows.Sample;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
internal enum ExecutionEnvironment
{
InProcess_Lockstep,
InProcess_OffThread,
InProcess_Concurrent
}
public class SampleSmokeTest
{
[Theory]
[InlineData(ExecutionMode.Lockstep)]
[InlineData(ExecutionMode.OffThread)]
internal async Task Test_RunSample_Step1Async(ExecutionMode executionMode)
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Concurrent)]
internal async Task Test_RunSample_Step1Async(ExecutionEnvironment environment)
{
using StringWriter writer = new();
await Step1EntryPoint.RunAsync(writer, executionMode);
await Step1EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment());
string result = writer.ToString();
string[] lines = result.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries);
@@ -34,13 +42,14 @@ public class SampleSmokeTest
}
[Theory]
[InlineData(ExecutionMode.Lockstep)]
[InlineData(ExecutionMode.OffThread)]
internal async Task Test_RunSample_Step1aAsync(ExecutionMode executionMode)
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Concurrent)]
internal async Task Test_RunSample_Step1aAsync(ExecutionEnvironment environment)
{
using StringWriter writer = new();
await Step1aEntryPoint.RunAsync(writer, executionMode);
await Step1aEntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment());
string result = writer.ToString();
string[] lines = result.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries);
@@ -54,37 +63,40 @@ public class SampleSmokeTest
}
[Theory]
[InlineData(ExecutionMode.Lockstep)]
[InlineData(ExecutionMode.OffThread)]
internal async Task Test_RunSample_Step2Async(ExecutionMode executionMode)
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Concurrent)]
internal async Task Test_RunSample_Step2Async(ExecutionEnvironment environment)
{
using StringWriter writer = new();
string spamResult = await Step2EntryPoint.RunAsync(writer, executionMode);
string spamResult = await Step2EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment());
Assert.Equal(RemoveSpamExecutor.ActionResult, spamResult);
string nonSpamResult = await Step2EntryPoint.RunAsync(writer, executionMode, "This is a valid message.");
string nonSpamResult = await Step2EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment(), "This is a valid message.");
Assert.Equal(RespondToMessageExecutor.ActionResult, nonSpamResult);
}
[Theory]
[InlineData(ExecutionMode.Lockstep)]
[InlineData(ExecutionMode.OffThread)]
internal async Task Test_RunSample_Step3Async(ExecutionMode executionMode)
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Concurrent)]
internal async Task Test_RunSample_Step3Async(ExecutionEnvironment environment)
{
using StringWriter writer = new();
string guessResult = await Step3EntryPoint.RunAsync(writer, executionMode);
string guessResult = await Step3EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment());
Assert.Equal("Guessed the number: 42", guessResult);
}
[Theory]
[InlineData(ExecutionMode.Lockstep)]
[InlineData(ExecutionMode.OffThread)]
internal async Task Test_RunSample_Step4Async(ExecutionMode executionMode)
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Concurrent)]
internal async Task Test_RunSample_Step4Async(ExecutionEnvironment environment)
{
using StringWriter writer = new();
@@ -93,14 +105,15 @@ public class SampleSmokeTest
("Your guess was too high. Try again.", 23),
("Your guess was too low. Try again.", 42));
string guessResult = await Step4EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, executionMode);
string guessResult = await Step4EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, environment.ToWorkflowExecutionEnvironment());
Assert.Equal("You guessed correctly! You Win!", guessResult);
}
[Theory]
[InlineData(ExecutionMode.Lockstep)]
[InlineData(ExecutionMode.OffThread)]
internal async Task Test_RunSample_Step5Async(ExecutionMode executionMode)
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Concurrent)]
internal async Task Test_RunSample_Step5Async(ExecutionEnvironment environment)
{
using StringWriter writer = new();
@@ -114,14 +127,15 @@ public class SampleSmokeTest
("Your guess was too low. Try again.", 42)
);
string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, executionMode);
string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, environment.ToWorkflowExecutionEnvironment());
Assert.Equal("You guessed correctly! You Win!", guessResult);
}
[Theory]
[InlineData(ExecutionMode.Lockstep)]
[InlineData(ExecutionMode.OffThread)]
internal async Task Test_RunSample_Step5aAsync(ExecutionMode executionMode)
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Concurrent)]
internal async Task Test_RunSample_Step5aAsync(ExecutionEnvironment environment)
{
using StringWriter writer = new();
@@ -135,14 +149,15 @@ public class SampleSmokeTest
("Your guess was too low. Try again.", 42)
);
string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, executionMode, rehydrateToRestore: true);
string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, environment.ToWorkflowExecutionEnvironment(), rehydrateToRestore: true);
Assert.Equal("You guessed correctly! You Win!", guessResult);
}
[Theory]
[InlineData(ExecutionMode.Lockstep)]
[InlineData(ExecutionMode.OffThread)]
internal async Task Test_RunSample_Step5bAsync(ExecutionMode executionMode)
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Concurrent)]
internal async Task Test_RunSample_Step5bAsync(ExecutionEnvironment environment)
{
using StringWriter writer = new();
@@ -160,18 +175,19 @@ public class SampleSmokeTest
options.MakeReadOnly();
CheckpointManager memoryJsonManager = CheckpointManager.CreateJson(new InMemoryJsonStore(), options);
string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, executionMode, rehydrateToRestore: true, checkpointManager: memoryJsonManager);
string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, environment.ToWorkflowExecutionEnvironment(), rehydrateToRestore: true, checkpointManager: memoryJsonManager);
Assert.Equal("You guessed correctly! You Win!", guessResult);
}
[Theory]
[InlineData(ExecutionMode.Lockstep)]
[InlineData(ExecutionMode.OffThread)]
internal async Task Test_RunSample_Step6Async(ExecutionMode executionMode)
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Concurrent)]
internal async Task Test_RunSample_Step6Async(ExecutionEnvironment environment)
{
using StringWriter writer = new();
await Step6EntryPoint.RunAsync(writer, executionMode);
await Step6EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment());
string result = writer.ToString();
string[] lines = result.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries);
@@ -199,9 +215,10 @@ public class SampleSmokeTest
}
[Theory]
[InlineData(ExecutionMode.Lockstep)]
[InlineData(ExecutionMode.OffThread)]
internal async Task Test_RunSample_Step8Async(ExecutionMode executionMode)
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Concurrent)]
internal async Task Test_RunSample_Step8Async(ExecutionEnvironment environment)
{
List<string> textsToProcess = [
"Hello world! This is a simple test.",
@@ -214,7 +231,7 @@ public class SampleSmokeTest
using StringWriter writer = new();
List<TextProcessingResult> results = await Step8EntryPoint.RunAsync(writer, executionMode, textsToProcess);
List<TextProcessingResult> results = await Step8EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment(), textsToProcess);
Assert.Equal(textsToProcess.Count, results.Count);
Assert.Collection(results,
@@ -237,12 +254,13 @@ public class SampleSmokeTest
}
[Theory]
[InlineData(ExecutionMode.Lockstep)]
[InlineData(ExecutionMode.OffThread)]
internal async Task Test_RunSample_Step9Async(ExecutionMode executionMode)
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Concurrent)]
internal async Task Test_RunSample_Step9Async(ExecutionEnvironment environment)
{
using StringWriter writer = new();
_ = await Step9EntryPoint.RunAsync(writer, executionMode);
_ = await Step9EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment());
}
}
@@ -8,6 +8,7 @@ using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Execution;
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Extensions.AI;
@@ -111,8 +112,10 @@ public class SpecializedExecutorSmokeTests
public sealed class TestAgentThread() : InMemoryAgentThread();
internal sealed class TestWorkflowContext : IWorkflowContext
internal sealed class TestWorkflowContext(string executorId, bool concurrentRunsEnabled = false) : IWorkflowContext
{
private readonly StateManager _stateManager = new();
public List<List<ChatMessage>> Updates { get; } = [];
public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default) =>
@@ -124,17 +127,19 @@ public class SpecializedExecutorSmokeTests
public ValueTask RequestHaltAsync() =>
default;
public ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default) =>
default;
public ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default)
=> this._stateManager.ClearStateAsync(new ScopeId(executorId, scopeName));
public ValueTask QueueStateUpdateAsync<T>(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default) =>
default;
public ValueTask QueueStateUpdateAsync<T>(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default)
=> value is null
? this._stateManager.ClearStateAsync(new ScopeId(executorId, scopeName), key)
: this._stateManager.WriteStateAsync(new ScopeId(executorId, scopeName), key, value);
public ValueTask<T?> ReadStateAsync<T>(string key, string? scopeName = null, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public ValueTask<T?> ReadStateAsync<T>(string key, string? scopeName = null, CancellationToken cancellationToken = default)
=> this._stateManager.ReadStateAsync<T>(new ScopeId(executorId, scopeName), key);
public ValueTask<HashSet<string>> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public ValueTask<HashSet<string>> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default)
=> this._stateManager.ReadKeysAsync(new ScopeId(executorId, scopeName));
public ValueTask SendMessageAsync(object message, string? targetId = null, CancellationToken cancellationToken = default)
{
@@ -150,7 +155,15 @@ public class SpecializedExecutorSmokeTests
return default;
}
public async ValueTask<T> ReadOrInitStateAsync<T>(string key, Func<T> initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default)
{
return (await this.ReadStateAsync<T>(key, scopeName, cancellationToken).ConfigureAwait(false))
?? initialStateFactory();
}
public IReadOnlyDictionary<string, string>? TraceContext => null;
public bool ConcurrentRunsEnabled => concurrentRunsEnabled;
}
[Fact]
@@ -177,7 +190,7 @@ public class SpecializedExecutorSmokeTests
TestAIAgent agent = new(expected);
AIAgentHostExecutor host = new(agent);
TestWorkflowContext collectingContext = new();
TestWorkflowContext collectingContext = new(host.Id);
await host.TakeTurnAsync(new TurnToken(emitEvents: false), collectingContext);
@@ -39,7 +39,14 @@ public class TestRunContext : IRunnerContext
public ValueTask SendMessageAsync(object message, string? targetId = null, CancellationToken cancellationToken = default)
=> runnerContext.SendMessageAsync(executorId, message, targetId, cancellationToken);
public ValueTask<T> ReadOrInitStateAsync<T>(string key, Func<T> initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default)
{
return new(initialStateFactory());
}
public IReadOnlyDictionary<string, string>? TraceContext => traceContext;
public bool ConcurrentRunsEnabled => runnerContext.ConcurrentRunsEnabled;
}
public List<WorkflowEvent> Events { get; } = [];
@@ -79,6 +86,7 @@ public class TestRunContext : IRunnerContext
public string StartingExecutorId { get; set; } = string.Empty;
public bool WithCheckpointing => throw new NotSupportedException();
public bool ConcurrentRunsEnabled => throw new NotSupportedException();
ValueTask<Executor> IRunnerContext.EnsureExecutorAsync(string executorId, IStepTracer? tracer, CancellationToken cancellationToken) =>
new(this.Executors[executorId]);
@@ -99,5 +107,6 @@ public class TestRunContext : IRunnerContext
public ValueTask SendMessageAsync<TMessage>(string senderId, [System.Diagnostics.CodeAnalysis.DisallowNull] TMessage message, CancellationToken cancellationToken = default)
=> this.SendMessageAsync(senderId, message, cancellationToken);
ValueTask ISuperStepJoinContext.AttachSuperstepAsync(ISuperStepRunner superStepRunner, CancellationToken cancellationToken) => default;
ValueTask<string> ISuperStepJoinContext.AttachSuperstepAsync(ISuperStepRunner superStepRunner, CancellationToken cancellationToken) => new(string.Empty);
ValueTask<bool> ISuperStepJoinContext.DetachSuperstepAsync(string joinId) => new(false);
}
@@ -0,0 +1,28 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Concurrent;
using System.Threading;
using Microsoft.Agents.AI.Workflows.Execution;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
internal sealed class TestRunState
{
public ConcurrentDictionary<string, ConcurrentQueue<object>> SentMessages = new();
public StateManager StateManager { get; } = new();
public ConcurrentQueue<WorkflowEvent> EmittedEvents { get; } = new();
public ConcurrentDictionary<string, ConcurrentQueue<object>> YieldedOutputs { get; } = new();
private int _haltRequests;
public int HaltRequests
{
get => Volatile.Read(ref this._haltRequests);
}
public void IncrementHaltRequests()
{
Interlocked.Increment(ref this._haltRequests);
}
public TestWorkflowContext ContextFor(string executorId) => new(executorId, this);
}
@@ -0,0 +1,75 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Execution;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
internal sealed class TestWorkflowContext : IWorkflowContext
{
private readonly string _executorId;
private readonly TestRunState _state;
public TestWorkflowContext(string executorId, TestRunState? state = null, bool concurrentRunsEnabled = false)
{
this._executorId = executorId;
this._state = state ?? new TestRunState();
this.ConcurrentRunsEnabled = concurrentRunsEnabled;
}
public bool ConcurrentRunsEnabled { get; }
public ConcurrentQueue<object> SentMessages => this._state.SentMessages.GetOrAdd(this._executorId, _ => new());
public StateManager StateManager => this._state.StateManager;
public ConcurrentQueue<WorkflowEvent> EmittedEvents => this._state.EmittedEvents;
public ConcurrentQueue<object> YieldedOutputs => this._state.YieldedOutputs.GetOrAdd(this._executorId, _ => new());
public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default)
{
this.EmittedEvents.Enqueue(workflowEvent);
return default;
}
public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default)
{
this.YieldedOutputs.Enqueue(output);
return this.AddEventAsync(new WorkflowOutputEvent(output, this._executorId), cancellationToken);
}
public ValueTask RequestHaltAsync()
{
this._state.IncrementHaltRequests();
return default;
}
public ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default)
=> this.StateManager.ClearStateAsync(new ScopeId(this._executorId, scopeName));
public ValueTask QueueStateUpdateAsync<T>(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default)
=> this.StateManager.WriteStateAsync(new ScopeId(this._executorId, scopeName), key, value);
public ValueTask<T?> ReadStateAsync<T>(string key, string? scopeName = null, CancellationToken cancellationToken = default)
=> this.StateManager.ReadStateAsync<T>(new ScopeId(this._executorId, scopeName), key);
public ValueTask<T> ReadOrInitStateAsync<T>(string key, Func<T> initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default)
=> this.StateManager.ReadOrInitStateAsync(new ScopeId(this._executorId, scopeName), key, initialStateFactory);
public ValueTask<HashSet<string>> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default)
=> this.StateManager.ReadKeysAsync(new ScopeId(this._executorId, scopeName));
public ValueTask SendMessageAsync(object message, string? targetId = null, CancellationToken cancellationToken = default)
{
this.SentMessages.Enqueue(message);
return default;
}
public IReadOnlyDictionary<string, string>? TraceContext => null;
}