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:
+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.
|
||||
|
||||
Reference in New Issue
Block a user