mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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:
committed by
GitHub
Unverified
parent
69e1ab0409
commit
331c750515
+1
-1
@@ -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!")]);
|
||||
|
||||
+43
-5
@@ -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}")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
|
||||
<NoWarn>$(NoWarn);MEAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+4
-8
@@ -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
-5
@@ -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
-8
@@ -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.";
|
||||
|
||||
|
||||
+37
-39
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+7
-8
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+17
-16
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+3
-6
@@ -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));
|
||||
|
||||
|
||||
+53
-26
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+127
-72
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-10
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user