From 331c7505159a8b380a5092a658c7d5ff5bb03d95 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 15 Oct 2025 17:34:17 -0400 Subject: [PATCH] .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 --- .../04_AgentWorkflowPatterns/Program.cs | 2 +- .../Interpreter/DeclarativeWorkflowContext.cs | 48 +- .../AIAgentExtensions.cs | 29 + .../AIAgentIDEqualityComparer.cs | 13 + .../AIAgentsAbstractionsExtensions.cs | 39 + .../AgentWorkflowBuilder.cs | 768 +----------------- .../AggregatingExecutor.cs | 39 +- .../AsyncBarrier.cs | 33 - .../AsyncCoordinator.cs | 42 - .../ChatProtocolExecutor.cs | 93 ++- .../{ => Execution}/ExecutionMode.cs | 0 .../Execution/ISuperStepJoinContext.cs | 4 +- .../Execution/InitLocked.cs | 45 - .../Execution/StateManager.cs | 68 +- .../Microsoft.Agents.AI.Workflows/Executor.cs | 35 +- .../ExecutorIsh.cs | 52 +- .../ExecutorRegistration.cs | 13 +- .../FunctionExecutor.cs | 8 +- .../GroupChatManager.cs | 83 ++ .../GroupChatWorkflowBuilder.cs | 70 ++ .../HandoffsWorkflowBuilder.cs | 168 ++++ .../IWorkflowContext.cs | 93 ++- .../InProc/InProcessExecutionEnvironment.cs | 17 +- .../InProc/InProcessExecutionOptions.cs | 10 + .../InProc/InProcessRunner.cs | 33 +- .../InProc/InProcessRunnerContext.cs | 48 +- .../InProcessExecution.cs | 5 + .../Reflection/ReflectingExecutor.cs | 5 +- .../RoundRobinGroupChatManager.cs | 72 ++ .../Specialized/AgentRunStreamingExecutor.cs | 44 + .../Specialized/ChatForwardingExecutor.cs | 20 + .../CollectChatMessagesExecutor.cs | 21 + .../Specialized/ConcurrentEndExecutor.cs | 64 ++ .../Specialized/GroupChatHost.cs | 62 ++ .../Specialized/HandoffAgentExecutor.cs | 116 +++ .../Specialized/HandoffState.cs | 11 + .../Specialized/HandoffTarget.cs | 10 + .../Specialized/HandoffsEndExecutor.cs | 15 + .../Specialized/HandoffsStartExecutor.cs | 22 + .../Specialized/OutputMessagesExecutor.cs | 23 + .../Specialized/WorkflowHostExecutor.cs | 22 +- .../StatefulExecutor.cs | 186 +++++ .../StatefulExecutorOptions.cs | 21 + .../Microsoft.Agents.AI.Workflows/Workflow.cs | 29 +- .../WorkflowHostAgent.cs | 16 +- .../WorkflowHostingExtensions.cs | 42 +- .../WorkflowOutputEvent.cs | 19 + .../WorkflowThread.cs | 36 +- .../WorkflowsJsonUtilities.cs | 4 + .../AgentWorkflowBuilderTests.cs | 14 +- .../ChatProtocolExecutorTests.cs | 52 +- .../ExecutionExtensions.cs | 14 +- ...osoft.Agents.AI.Workflows.UnitTests.csproj | 3 +- .../Sample/01_Simple_Workflow_Sequential.cs | 12 +- .../Sample/01a_Simple_Workflow_Sequential.cs | 7 +- .../Sample/02_Simple_Workflow_Condition.cs | 13 +- .../Sample/03_Simple_Workflow_Loop.cs | 76 +- .../04_Simple_Workflow_ExternalRequest.cs | 15 +- .../05_Simple_Workflow_Checkpointing.cs | 33 +- .../Sample/06_GroupChat_Workflow.cs | 9 +- .../Sample/08_Subworkflow_Simple.cs | 79 +- .../Sample/09_Subworkflow_ExternalRequest.cs | 199 +++-- .../SampleSmokeTest.cs | 108 ++- .../SpecializedExecutorSmokeTests.cs | 33 +- .../TestRunContext.cs | 11 +- .../TestRunState.cs | 28 + .../TestWorkflowContext.cs | 75 ++ 67 files changed, 2152 insertions(+), 1347 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentIDEqualityComparer.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/AsyncBarrier.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/AsyncCoordinator.cs rename dotnet/src/Microsoft.Agents.AI.Workflows/{ => Execution}/ExecutionMode.cs (100%) delete mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/Execution/InitLocked.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatManager.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatWorkflowBuilder.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessExecutionOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/RoundRobinGroupChatManager.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AgentRunStreamingExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/ChatForwardingExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/CollectChatMessagesExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/ConcurrentEndExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/GroupChatHost.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffTarget.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/OutputMessagesExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutorOptions.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRunState.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestWorkflowContext.cs diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/Program.cs index dc72f3c15c..8cc66ed18a 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/Program.cs @@ -70,7 +70,7 @@ public static class Program case "groupchat": await RunWorkflowAsync( - AgentWorkflowBuilder.CreateGroupChatBuilderWith(agents => new AgentWorkflowBuilder.RoundRobinGroupChatManager(agents) { MaximumIterationCount = 5 }) + AgentWorkflowBuilder.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 5 }) .AddParticipants(from lang in (string[])["French", "Spanish", "English"] select GetTranslationAgent(lang, client)) .Build(), [new(ChatRole.User, "Hello, world!")]); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowContext.cs index a2b4ae3a0f..60e50e6abe 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowContext.cs @@ -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? TraceContext => this.Source.TraceContext; + /// + public bool ConcurrentRunsEnabled => this.Source.ConcurrentRunsEnabled; + /// 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); + /// public async ValueTask ReadStateAsync(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(key, scopeName, cancellationToken).ConfigureAwait(false), + _ when !this.IsManagedScope(scopeName) => await this.Source.ReadStateAsync(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 ReadOrInitStateAsync(string key, Func 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 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 + } + } + /// public ValueTask> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default) => this.Source.ReadStateKeysAsync(scopeName, cancellationToken); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentExtensions.cs new file mode 100644 index 0000000000..b255a499ef --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentExtensions.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.RegularExpressions; + +namespace Microsoft.Agents.AI.Workflows; + +internal static partial class AIAgentExtensions +{ + /// + /// 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. + /// + public static string GetDescriptiveId(this AIAgent agent) + { + string id = string.IsNullOrEmpty(agent.Name) ? agent.Id : $"{agent.Name}_{agent.Id}"; + return InvalidNameCharsRegex().Replace(id, "_"); + } + + /// + /// Regex that flags any character other than ASCII digits or letters or the underscore. + /// +#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 +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentIDEqualityComparer.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentIDEqualityComparer.cs new file mode 100644 index 0000000000..ec557d0c53 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentIDEqualityComparer.cs @@ -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 +{ + 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; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentsAbstractionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentsAbstractionsExtensions.cs index 3d53d37f78..9f9906270e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentsAbstractionsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentsAbstractionsExtensions.cs @@ -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, }; + + /// + /// Iterates through looking for messages and swapping + /// any that have a different from to + /// . + /// + public static List? ChangeAssistantToUserForOtherParticipants(this List messages, string targetAgentName) + { + List? 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; + } + + /// + /// Undoes changes made by when passed the list of changes + /// made by that method. + /// + public static void ResetUserToAssistantForChangedRoles(this List? roleChanged) + { + if (roleChanged is not null) + { + foreach (var m in roleChanged) + { + m.Role = ChatRole.Assistant; + } + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs index 47d0aee346..907d10fe60 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs @@ -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); } - - /// - /// Executor that runs the agent and forwards all messages, input and output, to the next executor. - /// - private sealed class AgentRunStreamingExecutor(AIAgent agent, bool includeInputInOutput) : Executor(GetDescriptiveIdFromAgent(agent)), IResettableExecutor - { - private readonly List _pendingMessages = []; - - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => - routeBuilder - .AddHandler((message, _, __) => this._pendingMessages.Add(new(ChatRole.User, message))) - .AddHandler((message, _, __) => this._pendingMessages.Add(message)) - .AddHandler>((messages, _, __) => this._pendingMessages.AddRange(messages)) - .AddHandler((messages, _, __) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed - .AddHandler>((messages, _, __) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed - .AddHandler(async (token, context, cancellationToken) => - { - List messages = [.. this._pendingMessages]; - this._pendingMessages.Clear(); - - List? roleChanged = ChangeAssistantToUserForOtherParticipants(agent.DisplayName, messages); - - List 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; - } - } - - /// - /// Provides an executor that batches received chat messages that it then publishes as the final result - /// when receiving a . - /// - private sealed class OutputMessagesExecutor() : ChatProtocolExecutor("OutputMessages"), IResettableExecutor - { - protected override ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default) - => context.YieldOutputAsync(messages, cancellationToken); - - ValueTask IResettableExecutor.ResetAsync() => this.ResetAsync(); - } - - /// Executor that forwards all messages. - private sealed class ChatForwardingExecutor(string id) : Executor(id), IResettableExecutor - { - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => - routeBuilder - .AddHandler((message, context, cancellationToken) => context.SendMessageAsync(new ChatMessage(ChatRole.User, message), cancellationToken: cancellationToken)) - .AddHandler((message, context, cancellationToken) => context.SendMessageAsync(message, cancellationToken: cancellationToken)) - .AddHandler>((messages, context, cancellationToken) => context.SendMessageAsync(messages, cancellationToken: cancellationToken)) - .AddHandler((turnToken, context, cancellationToken) => context.SendMessageAsync(turnToken, cancellationToken: cancellationToken)); - - public ValueTask ResetAsync() => default; - } - - /// - /// Provides an executor that batches received chat messages that it then releases when - /// receiving a . - /// - private sealed class BatchChatMessagesToListExecutor(string id) : ChatProtocolExecutor(id), IResettableExecutor - { - protected override ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default) - => context.SendMessageAsync(messages, cancellationToken: cancellationToken); - - ValueTask IResettableExecutor.ResetAsync() => this.ResetAsync(); - } - - /// - /// 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. - /// - private sealed class ConcurrentEndExecutor : Executor, IResettableExecutor - { - private readonly int _expectedInputs; - private readonly Func>, List> _aggregator; - private List> _allResults; - private int _remaining; - - public ConcurrentEndExecutor(int expectedInputs, Func>, List> aggregator) : base("ConcurrentEnd") - { - this._expectedInputs = expectedInputs; - this._aggregator = Throw.IfNull(aggregator); - - this._allResults = new List>(expectedInputs); - this._remaining = expectedInputs; - } - - private void Reset() - { - this._allResults = new List>(this._expectedInputs); - this._remaining = this._expectedInputs; - } - - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => - routeBuilder.AddHandler>(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>(this._expectedInputs); - await context.YieldOutputAsync(this._aggregator(results), cancellationToken).ConfigureAwait(false); - } - }); - - public ValueTask ResetAsync() - { - this.Reset(); - return default; - } - } - - /// - /// Provides a builder for specifying the handoff relationships between agents and building the resulting workflow. - /// - public sealed class HandoffsWorkflowBuilder - { - private const string FunctionPrefix = "handoff_to_"; - private readonly AIAgent _initialAgent; - private readonly Dictionary> _targets = []; - private readonly HashSet _allAgents = new(AIAgentIDEqualityComparer.Instance); - - /// - /// Initializes a new instance of the class with no handoff relationships. - /// - /// The first agent to be invoked (prior to any handoff). - internal HandoffsWorkflowBuilder(AIAgent initialAgent) - { - this._initialAgent = initialAgent; - this._allAgents.Add(initialAgent); - } - - /// - /// Gets or sets additional instructions to provide to an agent that has handoffs about how and when to perform them. - /// - /// - /// By default, simple instructions are included. This may be set to to avoid including - /// any additional instructions, or may be customized to provide more specific guidance. - /// - 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}`; 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. - """; - - /// - /// Adds handoff relationships from a source agent to one or more target agents. - /// - /// The source agent. - /// The target agents to add as handoff targets for the source agent. - /// The updated instance. - /// The handoff reason for each target in is derived from that agent's description or name. - public HandoffsWorkflowBuilder WithHandoffs(AIAgent from, IEnumerable 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; - } - - /// - /// Adds handoff relationships from one or more sources agent to a target agent. - /// - /// The source agents. - /// The target agent to add as a handoff target for each source agent. - /// - /// The reason the should hand off to the . - /// If , the reason is derived from 's description or name. - /// - /// The updated instance. - public HandoffsWorkflowBuilder WithHandoffs(IEnumerable 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; - } - - /// - /// Adds a handoff relationship from a source agent to a target agent with a custom handoff reason. - /// - /// The source agent. - /// The target agent. - /// - /// The reason the should hand off to the . - /// If , the reason is derived from 's description or name. - /// - /// The updated instance. - 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; - } - - /// - /// Builds a composed of agents that operate via handoffs, with the next - /// agent to process messages selected by the current agent. - /// - /// The workflow built based on the handoffs in the builder. - public Workflow Build() - { - StartHandoffsExecutor start = new(); - EndHandoffsExecutor end = new(); - WorkflowBuilder builder = new(start); - - // Create an AgentExecutor for each again. - Dictionary 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? targets) ? targets : []); - } - - // Build the workflow. - return builder.WithOutputFrom(end).Build(); - } - - /// Describes a handoff to a specific target . - 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(); - } - - /// Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token. - private sealed class StartHandoffsExecutor() : Executor("HandoffStart"), IResettableExecutor - { - private readonly List _pendingMessages = []; - - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => - routeBuilder - .AddHandler((message, context, _) => this._pendingMessages.Add(new(ChatRole.User, message))) - .AddHandler((message, context, _) => this._pendingMessages.Add(message)) - .AddHandler>((messages, _, __) => this._pendingMessages.AddRange(messages)) - .AddHandler((messages, _, __) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed - .AddHandler>((messages, _, __) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed - .AddHandler(async (token, context, cancellationToken) => - { - var messages = new List(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; - } - } - - /// Executor used at the end of a handoff workflow to raise a final completed event. - private sealed class EndHandoffsExecutor() : Executor("HandoffEnd"), IResettableExecutor - { - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => - routeBuilder.AddHandler((handoff, context, cancellationToken) => - context.YieldOutputAsync(handoff.Messages, cancellationToken)); - - public ValueTask ResetAsync() => default; - } - - /// Executor used to represent an agent in a handoffs workflow, responding to events. - 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 _handoffFunctionNames = []; - private ChatClientAgentRunOptions? _agentOptions; - - public void Initialize( - WorkflowBuilder builder, - Executor end, - Dictionary executors, - HashSet 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(state => state?.InvokedHandoff == handoffFunc.Name, executors[handoff.Target.Id]); - } - } - - sb.WithDefault(end); - }); - - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => - routeBuilder.AddHandler(async (handoffState, context, cancellationToken) => - { - string? requestedHandoff = null; - List updates = []; - List allMessages = handoffState.Messages; - - List? 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 Messages); - } - - /// - /// A manager that manages the flow of a group chat. - /// - public abstract class GroupChatManager - { - private int _maximumIterationCount = 40; - - /// - /// Initializes a new instance of the class. - /// - protected GroupChatManager() { } - - /// - /// Gets the number of iterations in the group chat so far. - /// - public int IterationCount { get; internal set; } - - /// - /// Gets or sets the maximum number of iterations allowed. - /// - /// - /// Each iteration involves a single interaction with a participating agent. - /// The default is 40. - /// - public int MaximumIterationCount - { - get => this._maximumIterationCount; - set => this._maximumIterationCount = Throw.IfLessThan(value, 1); - } - - /// - /// Selects the next agent to participate in the group chat based on the provided chat history and team. - /// - /// The chat history to consider. - /// The to monitor for cancellation requests. - /// The default is . - /// The next to speak. This agent must be part of the chat. - protected internal abstract ValueTask SelectNextAgentAsync( - IReadOnlyList history, - CancellationToken cancellationToken = default); - - /// - /// Filters the chat history before it's passed to the next agent. - /// - /// The chat history to filter. - /// The to monitor for cancellation requests. - /// The default is . - /// The filtered chat history. - protected internal virtual ValueTask> UpdateHistoryAsync( - IReadOnlyList history, - CancellationToken cancellationToken = default) => - new(history); - - /// - /// Determines whether the group chat should be terminated based on the provided chat history and iteration count. - /// - /// The chat history to consider. - /// The to monitor for cancellation requests. - /// The default is . - /// A indicating whether the chat should be terminated. - protected internal virtual ValueTask ShouldTerminateAsync( - IReadOnlyList history, - CancellationToken cancellationToken = default) => - new(this.MaximumIterationCount is int max && this.IterationCount >= max); - - /// - /// Resets the state of the manager for a new group chat session. - /// - protected internal virtual void Reset() - { - this.IterationCount = 0; - } - } - - /// - /// Provides a that selects agents in a round-robin fashion. - /// - public class RoundRobinGroupChatManager : GroupChatManager - { - private readonly IReadOnlyList _agents; - private readonly Func, CancellationToken, ValueTask>? _shouldTerminateFunc; - private int _nextIndex; - - /// - /// Initializes a new instance of the class. - /// - /// The agents to be managed as part of this workflow. - /// - /// 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. - /// - public RoundRobinGroupChatManager( - IReadOnlyList agents, - Func, CancellationToken, ValueTask>? shouldTerminateFunc = null) - { - Throw.IfNullOrEmpty(agents); - foreach (var agent in agents) - { - Throw.IfNull(agent, nameof(agents)); - } - - this._agents = agents; - this._shouldTerminateFunc = shouldTerminateFunc; - } - - /// - protected internal override ValueTask SelectNextAgentAsync( - IReadOnlyList history, CancellationToken cancellationToken = default) - { - AIAgent nextAgent = this._agents[this._nextIndex]; - - this._nextIndex = (this._nextIndex + 1) % this._agents.Count; - - return new ValueTask(nextAgent); - } - - /// - protected internal override async ValueTask ShouldTerminateAsync( - IReadOnlyList 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); - } - - /// - protected internal override void Reset() - { - base.Reset(); - this._nextIndex = 0; - } - } - - /// - /// Provides a builder for specifying group chat relationships between agents and building the resulting workflow. - /// - public sealed class GroupChatWorkflowBuilder - { - private readonly Func, GroupChatManager> _managerFactory; - private readonly HashSet _participants = new(AIAgentIDEqualityComparer.Instance); - - internal GroupChatWorkflowBuilder(Func, GroupChatManager> managerFactory) => - this._managerFactory = managerFactory; - - /// - /// Adds the specified as participants to the group chat workflow. - /// - /// The agents to add as participants. - /// This instance of the . - public GroupChatWorkflowBuilder AddParticipants(params IEnumerable 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; - } - - /// - /// Builds a composed of agents that operate via group chat, with the next - /// agent to process messages selected by the group chat manager. - /// - /// The workflow built based on the group chat in the builder. - public Workflow Build() - { - AIAgent[] agents = this._participants.ToArray(); - Dictionary 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 agentMap, Func, GroupChatManager> managerFactory) : Executor("GroupChatHost"), IResettableExecutor - { - private readonly AIAgent[] _agents = agents; - private readonly Dictionary _agentMap = agentMap; - private readonly Func, GroupChatManager> _managerFactory = managerFactory; - private readonly List _pendingMessages = []; - - private GroupChatManager? _manager; - - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => routeBuilder - .AddHandler((message, context, _) => this._pendingMessages.Add(new(ChatRole.User, message))) - .AddHandler((message, context, _) => this._pendingMessages.Add(message)) - .AddHandler>((messages, _, __) => this._pendingMessages.AddRange(messages)) - .AddHandler((messages, _, __) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed - .AddHandler>((messages, _, __) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed - .AddHandler(async (token, context, cancellationToken) => - { - List 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; - } - } - } - - /// - /// Iterates through looking for messages and swapping - /// any that have a different from to . - /// - private static List? ChangeAssistantToUserForOtherParticipants(string targetAgentName, List messages) - { - List? 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; - } - - /// - /// Undoes changes made by - /// when passed the list of changes made by that method. - /// - private static void ResetUserToAssistantForChangedRoles(List? roleChanged) - { - if (roleChanged is not null) - { - foreach (var m in roleChanged) - { - m.Role = ChatRole.Assistant; - } - } - } - - /// 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. - private static string GetDescriptiveIdFromAgent(AIAgent agent) - { - string id = string.IsNullOrEmpty(agent.Name) ? agent.Id : $"{agent.Name}_{agent.Id}"; - return InvalidNameCharsRegex().Replace(id, "_"); - } - - /// Regex that flags any character other than ASCII digits or letters or the underscore. -#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 - { - 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; - } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AggregatingExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AggregatingExecutor.cs index 06f6ce243a..5aa31513a5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AggregatingExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AggregatingExecutor.cs @@ -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. /// Optional configuration settings for the executor. If null, default options are used. +/// Declare that this executor may be used simultaneously by multiple runs safely. /// public class AggregatingExecutor(string id, Func aggregator, - ExecutorOptions? options = null) : Executor(id, options) + ExecutorOptions? options = null, + bool declareCrossRunShareable = false) : Executor(id, options, declareCrossRunShareable) { private const string AggregateStateKey = "Aggregate"; - private TAggregate? _runningAggregate; /// - public override ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default) + public override async ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken) { - this._runningAggregate = aggregator(this._runningAggregate, message); - return new(this._runningAggregate); - } + TAggregate? runningAggregate = default; + await context.InvokeWithStateAsync(InvokeAggregatorAsync, AggregateStateKey, cancellationToken: cancellationToken) + .ConfigureAwait(false); - /// - 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 InvokeAggregatorAsync(PortableValue? maybeState, IWorkflowContext context, CancellationToken cancellationToken) + { + if (maybeState == null || !maybeState.Is(out runningAggregate)) + { + runningAggregate = default; + } - /// - 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(AggregateStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); + if (runningAggregate == null) + { + return new((PortableValue?)null); + } + + return new(new PortableValue(runningAggregate)); + } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AsyncBarrier.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AsyncBarrier.cs deleted file mode 100644 index 2cd0e84347..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AsyncBarrier.cs +++ /dev/null @@ -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> _completionSource = new(); - - public async ValueTask JoinAsync(CancellationToken cancellationToken = default) - { - this._completionSource.Init(() => new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)); - TaskCompletionSource 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 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; - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AsyncCoordinator.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AsyncCoordinator.cs deleted file mode 100644 index e1478396c8..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AsyncCoordinator.cs +++ /dev/null @@ -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; - - /// - /// Wait for the Coordination owner to mark the next coordination point, then continue execution. - /// - /// The to monitor for cancellation requests. - /// The default is . - /// - /// A task that represents the asynchronous operation. The task result is - /// if the wait was completed; otherwise, for example, if the wait was cancelled, . - /// - public async ValueTask 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); - } - - /// - /// Marks the coordination point and releases any waiting operations if a coordination barrier is present. - /// - /// true if a coordination barrier was released; otherwise, false. - public bool MarkCoordinationPoint() - { - AsyncBarrier? maybeBarrier = Interlocked.Exchange(ref this._coordinationBarrier, null); - return maybeBarrier?.ReleaseBarrier() ?? false; - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ChatProtocolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ChatProtocolExecutor.cs index 9363ca59a0..480c2e0ce3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/ChatProtocolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ChatProtocolExecutor.cs @@ -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> { - private List _pendingMessages = []; - private readonly ChatRole? _stringMessageChatRole = options?.StringMessageChatRole; + private readonly static Func> 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((message, _, __) => this._pendingMessages.Add(new(this._stringMessageChatRole.Value, message))); + routeBuilder = routeBuilder.AddHandler( + (message, context) => this.AddMessageAsync(new(this._stringMessageChatRole.Value, message), context)); } - // Routing requires exact type matches. The runtime may dispatch either List or ChatMessage[]. - return routeBuilder.AddHandler((message, _, __) => this._pendingMessages.Add(message)) - .AddHandler>((messages, _, __) => this._pendingMessages.AddRange(messages)) - .AddHandler((messages, _, __) => this._pendingMessages.AddRange(messages)) + return routeBuilder.AddHandler(this.AddMessageAsync) + .AddHandler>(this.AddMessagesAsync) + .AddHandler(this.AddMessagesAsync) + .AddHandler>(this.AddMessagesAsync) .AddHandler(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?> ForwardMessageAsync(List? maybePendingMessages, IWorkflowContext context, CancellationToken cancelationToken) + { + maybePendingMessages ??= s_initFunction(); + maybePendingMessages.Add(message); + return new(maybePendingMessages); + } + } + + protected ValueTask AddMessagesAsync(IEnumerable messages, IWorkflowContext context, CancellationToken cancellationToken = default) + { + return this.InvokeWithStateAsync(ForwardMessageAsync, context, cancellationToken: cancellationToken); + + ValueTask?> ForwardMessageAsync(List? 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?> InvokeTakeTurnAsync(List? 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 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(PendingMessagesStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); - if (messagesValue.HasValue) - { - List messages = messagesValue.Value.DeserializeMessages(); - this._pendingMessages.AddRange(messages); - } - } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutionMode.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ExecutionMode.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.AI.Workflows/ExecutionMode.cs rename to dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ExecutionMode.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepJoinContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepJoinContext.cs index ed4ded17d6..f4af19bcfd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepJoinContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepJoinContext.cs @@ -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(string senderId, [DisallowNull] TMessage message, CancellationToken cancellationToken = default); - ValueTask AttachSuperstepAsync(ISuperStepRunner superStepRunner, CancellationToken cancellationToken = default); + ValueTask AttachSuperstepAsync(ISuperStepRunner superStepRunner, CancellationToken cancellationToken = default); + ValueTask DetachSuperstepAsync(string id); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/InitLocked.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/InitLocked.cs deleted file mode 100644 index d2fe3268d2..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/InitLocked.cs +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Threading; - -namespace Microsoft.Agents.AI.Workflows.Execution; - -internal class InitLocked() where T : class -{ - private int _writers; - private T? _value; - - public T? Get() - { - return this._value; - } - - public bool Init(Func 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; - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StateManager.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StateManager.cs index ffa289eaf9..81ffedc6af 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StateManager.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StateManager.cs @@ -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 ReadStateAsync(string executorId, string? scopeName, string key) => this.ReadStateAsync(new ScopeId(Throw.IfNullOrEmpty(executorId), scopeName), key); - public ValueTask ReadStateAsync(ScopeId scopeId, string key) + public ValueTask ReadOrInitStateAsync(string executorId, string? scopeName, string key, Func initialStateFactory) + => this.ReadOrInitStateAsync(new ScopeId(Throw.IfNullOrEmpty(executorId), scopeName), key, initialStateFactory); + + private async ValueTask ReadValueOrDefaultAsync(ScopeId scopeId, string key, Func? 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(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(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 ReadStateAsync(ScopeId scopeId, string key) + => this.ReadValueOrDefaultAsync(scopeId, key); + + public async ValueTask ReadOrInitStateAsync(ScopeId scopeId, string key, Func initialStateFactory) + { + return (await this.ReadValueOrDefaultAsync(scopeId, key, initialStateFactory, initOnDefault: true) + .ConfigureAwait(false))!; } public ValueTask WriteStateAsync(string executorId, string? scopeName, string key, T value) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs index cd97fcaea4..97c7932cf0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs @@ -32,12 +32,25 @@ public abstract class Executor : IIdentified /// /// A unique identifier for the executor. /// Configuration options for the executor. If null, default options will be used. - protected Executor(string id, ExecutorOptions? options = null) + /// Declare that this executor may be used simultaneously by multiple runs safely. + 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; } + /// /// Gets the configuration options for the executor. /// @@ -48,6 +61,16 @@ public abstract class Executor : IIdentified /// protected abstract RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder); + /// + /// Perform any asynchronous initialization required by the executor. This method is called once per executor instance, + /// + /// The workflow context in which the executor executes. + /// The to monitor for cancellation requests. + /// The default is . + /// A representing the asynchronous operation. + protected internal virtual ValueTask InitializeAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + => default; + /// /// Override this method to declare the types of messages this executor can send. /// @@ -206,8 +229,9 @@ public abstract class Executor : IIdentified /// The type of input message. /// A unique identifier for the executor. /// Configuration options for the executor. If null, default options will be used. -public abstract class Executor(string id, ExecutorOptions? options = null) - : Executor(id, options), IMessageHandler +/// Declare that this executor may be used simultaneously by multiple runs safely. +public abstract class Executor(string id, ExecutorOptions? options = null, bool declareCrossRunShareable = false) + : Executor(id, options, declareCrossRunShareable), IMessageHandler { /// protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => @@ -224,8 +248,9 @@ public abstract class Executor(string id, ExecutorOptions? options = nul /// The type of output message. /// A unique identifier for the executor. /// Configuration options for the executor. If null, default options will be used. -public abstract class Executor(string id, ExecutorOptions? options = null) - : Executor(id, options ?? ExecutorOptions.Default), +/// Declare that this executor may be used simultaneously by multiple runs safely. +public abstract class Executor(string id, ExecutorOptions? options = null, bool declareCrossRunShareable = false) + : Executor(id, options ?? ExecutorOptions.Default, declareCrossRunShareable), IMessageHandler { /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorIsh.cs index 59c26284d8..2151dadce4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorIsh.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorIsh.cs @@ -13,6 +13,43 @@ namespace Microsoft.Agents.AI.Workflows; /// public static class ExecutorIshConfigurationExtensions { + /// + /// Configures a factory method for creating an of type , using the + /// type name as the id. + /// + /// + /// Note that Executor Ids must be unique within a workflow. + /// + /// Although this will generally result in a delay-instantiated once messages are available + /// for it, if this is used as a start node of a typed via , + /// it will be instantiated as part of the workflow's construction, to validate that its input type matches the + /// demanded TInput. + /// + /// The type of the resulting executor + /// The factory method. + /// An ExecutorIsh instance that resolves to the result of the factory call when messages get sent to it. + public static ExecutorIsh ConfigureFactory(this Func> factoryAsync) + where TExecutor : Executor + => ConfigureFactory((config, runId) => factoryAsync(config.Id, runId), typeof(TExecutor).Name, options: null); + + /// + /// Configures a factory method for creating an of type , with + /// the specified id. + /// + /// + /// Although this will generally result in a delay-instantiated once messages are available + /// for it, if this is used as a start node of a typed via , + /// it will be instantiated as part of the workflow's construction, to validate that its input type matches the + /// demanded TInput. + /// + /// The type of the resulting executor + /// The factory method. + /// An id for the executor to be instantiated. + /// An ExecutorIsh instance that resolves to the result of the factory call when messages get sent to it. + public static ExecutorIsh ConfigureFactory(this Func> factoryAsync, string id) + where TExecutor : Executor + => ConfigureFactory((_, runId) => factoryAsync(id, runId), id, options: null); + /// /// Configures a factory method for creating an of type , with /// the specified id and options. @@ -77,9 +114,10 @@ public static class ExecutorIshConfigurationExtensions /// A delegate that defines the asynchronous function to execute for each input message. /// A optional unique identifier for the executor. If null, will use the function argument as an id. /// Configuration options for the executor. If null, default options will be used. + /// Declare that the message handler may be used simultaneously by multiple runs concurrently. /// An ExecutorIsh instance that wraps the provided asynchronous message handler and configuration. - public static ExecutorIsh AsExecutor(this Func messageHandlerAsync, string id, ExecutorOptions? options = null) - => new FunctionExecutor(id, messageHandlerAsync, options).ToExecutorIsh(messageHandlerAsync); + public static ExecutorIsh AsExecutor(this Func messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false) + => new FunctionExecutor(id, messageHandlerAsync, options, declareCrossRunShareable: threadsafe).ToExecutorIsh(messageHandlerAsync); /// /// Configures a function-based asynchronous message handler as an executor with the specified identifier and @@ -90,9 +128,10 @@ public static class ExecutorIshConfigurationExtensions /// A delegate that defines the asynchronous function to execute for each input message. /// A unique identifier for the executor. /// Configuration options for the executor. If null, default options will be used. + /// Declare that the message handler may be used simultaneously by multiple runs concurrently. /// An ExecutorIsh instance that wraps the provided asynchronous message handler and configuration. - public static ExecutorIsh AsExecutor(this Func> messageHandlerAsync, string id, ExecutorOptions? options = null) - => new FunctionExecutor(Throw.IfNull(id), messageHandlerAsync, options).ToExecutorIsh(messageHandlerAsync); + public static ExecutorIsh AsExecutor(this Func> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false) + => new FunctionExecutor(Throw.IfNull(id), messageHandlerAsync, options, declareCrossRunShareable: threadsafe).ToExecutorIsh(messageHandlerAsync); /// /// Configures a function-based aggregating executor with the specified identifier and options. @@ -102,9 +141,10 @@ public static class ExecutorIshConfigurationExtensions /// A delegate the defines the aggregation procedure /// A unique identifier for the executor. /// Configuration options for the executor. If null, default options will be used. + /// Declare that the message handler may be used simultaneously by multiple runs concurrently. /// An ExecutorIsh instance that wraps the provided asynchronous message handler and configuration. - public static ExecutorIsh AsExecutor(this Func aggregatorFunc, string id, ExecutorOptions? options = null) - => new AggregatingExecutor(id, aggregatorFunc, options); + public static ExecutorIsh AsExecutor(this Func aggregatorFunc, string id, ExecutorOptions? options = null, bool threadsafe = false) + => new AggregatingExecutor(id, aggregatorFunc, options, declareCrossRunShareable: threadsafe); } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorRegistration.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorRegistration.cs index 80f57680e6..661cde559f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorRegistration.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorRegistration.cs @@ -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 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; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/FunctionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/FunctionExecutor.cs index dd692165c7..63db9456b4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/FunctionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/FunctionExecutor.cs @@ -13,9 +13,11 @@ namespace Microsoft.Agents.AI.Workflows; /// A unique identifier for the executor. /// A delegate that defines the asynchronous function to execute for each input message. /// Configuration options for the executor. If null, default options will be used. +/// Declare that this executor may be used simultaneously by multiple runs safely. public class FunctionExecutor(string id, Func handlerAsync, - ExecutorOptions? options = null) : Executor(id, options) + ExecutorOptions? options = null, + bool declareCrossRunShareable = false) : Executor(id, options, declareCrossRunShareable) { internal static Func WrapAction(Action handlerSync) { @@ -49,9 +51,11 @@ public class FunctionExecutor(string id, /// A unique identifier for the executor. /// A delegate that defines the asynchronous function to execute for each input message. /// Configuration options for the executor. If null, default options will be used. +/// Declare that this executor may be used simultaneously by multiple runs safely. public class FunctionExecutor(string id, Func> handlerAsync, - ExecutorOptions? options = null) : Executor(id, options) + ExecutorOptions? options = null, + bool declareCrossRunShareable = false) : Executor(id, options, declareCrossRunShareable) { internal static Func> WrapFunc(Func handlerSync) { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatManager.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatManager.cs new file mode 100644 index 0000000000..9d3d55b33f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatManager.cs @@ -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; + +/// +/// A manager that manages the flow of a group chat. +/// +public abstract class GroupChatManager +{ + private int _maximumIterationCount = 40; + + /// + /// Initializes a new instance of the class. + /// + protected GroupChatManager() { } + + /// + /// Gets the number of iterations in the group chat so far. + /// + public int IterationCount { get; internal set; } + + /// + /// Gets or sets the maximum number of iterations allowed. + /// + /// + /// Each iteration involves a single interaction with a participating agent. + /// The default is 40. + /// + public int MaximumIterationCount + { + get => this._maximumIterationCount; + set => this._maximumIterationCount = Throw.IfLessThan(value, 1); + } + + /// + /// Selects the next agent to participate in the group chat based on the provided chat history and team. + /// + /// The chat history to consider. + /// The to monitor for cancellation requests. + /// The default is . + /// The next to speak. This agent must be part of the chat. + protected internal abstract ValueTask SelectNextAgentAsync( + IReadOnlyList history, + CancellationToken cancellationToken = default); + + /// + /// Filters the chat history before it's passed to the next agent. + /// + /// The chat history to filter. + /// The to monitor for cancellation requests. + /// The default is . + /// The filtered chat history. + protected internal virtual ValueTask> UpdateHistoryAsync( + IReadOnlyList history, + CancellationToken cancellationToken = default) => + new(history); + + /// + /// Determines whether the group chat should be terminated based on the provided chat history and iteration count. + /// + /// The chat history to consider. + /// The to monitor for cancellation requests. + /// The default is . + /// A indicating whether the chat should be terminated. + protected internal virtual ValueTask ShouldTerminateAsync( + IReadOnlyList history, + CancellationToken cancellationToken = default) => + new(this.MaximumIterationCount is int max && this.IterationCount >= max); + + /// + /// Resets the state of the manager for a new group chat session. + /// + protected internal virtual void Reset() + { + this.IterationCount = 0; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatWorkflowBuilder.cs new file mode 100644 index 0000000000..92b73083a9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatWorkflowBuilder.cs @@ -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; + +/// +/// Provides a builder for specifying group chat relationships between agents and building the resulting workflow. +/// +public sealed class GroupChatWorkflowBuilder +{ + private readonly Func, GroupChatManager> _managerFactory; + private readonly HashSet _participants = new(AIAgentIDEqualityComparer.Instance); + + internal GroupChatWorkflowBuilder(Func, GroupChatManager> managerFactory) => + this._managerFactory = managerFactory; + + /// + /// Adds the specified as participants to the group chat workflow. + /// + /// The agents to add as participants. + /// This instance of the . + public GroupChatWorkflowBuilder AddParticipants(params IEnumerable 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; + } + + /// + /// Builds a composed of agents that operate via group chat, with the next + /// agent to process messages selected by the group chat manager. + /// + /// The workflow built based on the group chat in the builder. + public Workflow Build() + { + AIAgent[] agents = this._participants.ToArray(); + Dictionary agentMap = agents.ToDictionary(a => a, a => (ExecutorIsh)new AgentRunStreamingExecutor(a, includeInputInOutput: true)); + + Func> 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(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs new file mode 100644 index 0000000000..4362f0834f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs @@ -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; + +/// +/// Provides a builder for specifying the handoff relationships between agents and building the resulting workflow. +/// +public sealed class HandoffsWorkflowBuilder +{ + internal const string FunctionPrefix = "handoff_to_"; + private readonly AIAgent _initialAgent; + private readonly Dictionary> _targets = []; + private readonly HashSet _allAgents = new(AIAgentIDEqualityComparer.Instance); + + /// + /// Initializes a new instance of the class with no handoff relationships. + /// + /// The first agent to be invoked (prior to any handoff). + internal HandoffsWorkflowBuilder(AIAgent initialAgent) + { + this._initialAgent = initialAgent; + this._allAgents.Add(initialAgent); + } + + /// + /// Gets or sets additional instructions to provide to an agent that has handoffs about how and when to perform them. + /// + /// + /// By default, simple instructions are included. This may be set to to avoid including + /// any additional instructions, or may be customized to provide more specific guidance. + /// + 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}`; 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. + """; + + /// + /// Adds handoff relationships from a source agent to one or more target agents. + /// + /// The source agent. + /// The target agents to add as handoff targets for the source agent. + /// The updated instance. + /// The handoff reason for each target in is derived from that agent's description or name. + public HandoffsWorkflowBuilder WithHandoffs(AIAgent from, IEnumerable 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; + } + + /// + /// Adds handoff relationships from one or more sources agent to a target agent. + /// + /// The source agents. + /// The target agent to add as a handoff target for each source agent. + /// + /// The reason the should hand off to the . + /// If , the reason is derived from 's description or name. + /// + /// The updated instance. + public HandoffsWorkflowBuilder WithHandoffs(IEnumerable 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; + } + + /// + /// Adds a handoff relationship from a source agent to a target agent with a custom handoff reason. + /// + /// The source agent. + /// The target agent. + /// + /// The reason the should hand off to the . + /// If , the reason is derived from 's description or name. + /// + /// The updated instance. + 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; + } + + /// + /// Builds a composed of agents that operate via handoffs, with the next + /// agent to process messages selected by the current agent. + /// + /// The workflow built based on the handoffs in the builder. + public Workflow Build() + { + HandoffsStartExecutor start = new(); + HandoffsEndExecutor end = new(); + WorkflowBuilder builder = new(start); + + // Create an AgentExecutor for each again. + Dictionary 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? targets) ? targets : []); + } + + // Build the workflow. + return builder.WithOutputFrom(end).Build(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/IWorkflowContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/IWorkflowContext.cs index efc074c2d4..57dcdc2b64 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/IWorkflowContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/IWorkflowContext.cs @@ -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; +/// +/// Provides extension methods for working with instances. +/// +public static class WorkflowContextExtensions +{ + /// + /// Invokes an asynchronous operation that reads, updates, and persists workflow state associated with the specified + /// key. + /// + /// The type of the state object to read, update, and persist. + /// The workflow context used to access and update state. + /// A delegate that receives the current state, workflow context, and cancellation token, and returns the updated + /// state asynchronously. + /// The key identifying the state to read and update. Cannot be null or empty. + /// An optional scope name that further qualifies the state key. If null, the default scope is used. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// A ValueTask that represents the asynchronous operation. + public static async ValueTask InvokeWithStateAsync(this IWorkflowContext context, + Func> invocation, + string key, + string? scopeName = null, + CancellationToken cancellationToken = default) + { + TState? state = await context.ReadStateAsync(key, scopeName, cancellationToken).ConfigureAwait(false); + state = await invocation(state, context, cancellationToken).ConfigureAwait(false); + await context.QueueStateUpdateAsync(key, state, scopeName, cancellationToken).ConfigureAwait(false); + } + + /// + /// Invokes an asynchronous operation that reads, updates, and persists workflow state associated with the specified + /// key. + /// + /// The type of the state object to read, update, and persist. + /// The workflow context used to access and update state. + /// A delegate that receives the current state, workflow context, and cancellation token, and returns the updated + /// state asynchronously. + /// The key identifying the state to read and update. Cannot be null or empty. + /// A factory to initialize state to if it is not set at the provided key. + /// An optional scope name that further qualifies the state key. If null, the default scope is used. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// A ValueTask that represents the asynchronous operation. + public static async ValueTask InvokeWithStateAsync(this IWorkflowContext context, + Func> invocation, + string key, + Func 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); + } +} + /// /// Provides services for an during the execution of a workflow. /// @@ -78,6 +133,24 @@ public interface IWorkflowContext /// A representing the asynchronous operation. ValueTask ReadStateAsync(string key, string? scopeName = null, CancellationToken cancellationToken = default); + /// + /// Reads or initialized a state value from the workflow's state store. If no scope is provided, the executor's + /// default scope is used. + /// + /// + /// 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. + /// + /// The type of the state value. + /// The key of the state value. + /// A factory to initialize the state if the key has no value associated with it. + /// An optional name that specifies the scope to read. If null, the default scope is + /// used. + /// The to monitor for cancellation requests. + /// The default is . + /// A representing the asynchronous operation. + ValueTask ReadOrInitStateAsync(string key, Func initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default); + #if NET // See above for musings about this construction /// /// 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 /// The key of the state value. /// The to monitor for cancellation requests. /// A representing the asynchronous operation. - ValueTask ReadStateAsync(string key, CancellationToken cancellationToken) => this.ReadStateAsync(key, null, cancellationToken); + ValueTask ReadStateAsync(string key, CancellationToken cancellationToken) + => this.ReadStateAsync(key, null, cancellationToken); + /// + /// Reads a state value from the workflow's state store. If no scope is provided, the executor's + /// default scope is used. + /// + /// The type of the state value. + /// The key of the state value. + /// A factory to initialize the state if the key has no value associated with it. + /// The to monitor for cancellation requests. + /// The default is . + /// A representing the asynchronous operation. + ValueTask ReadOrInitStateAsync(string key, Func initialStateFactory, CancellationToken cancellationToken) + => this.ReadOrInitStateAsync(key, initialStateFactory, null, cancellationToken); #endif /// @@ -169,4 +255,9 @@ public interface IWorkflowContext /// The trace context associated with the current message about to be processed by the executor, if any. /// IReadOnlyDictionary? TraceContext { get; } + + /// + /// Whether the current execution environment support concurrent runs against the same workflow instance. + /// + bool ConcurrentRunsEnabled { get; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessExecutionEnvironment.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessExecutionEnvironment.cs index 525632862f..219b4642cd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessExecutionEnvironment.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessExecutionEnvironment.cs @@ -15,22 +15,25 @@ namespace Microsoft.Agents.AI.Workflows.InProc; /// 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 BeginRunAsync(Workflow workflow, ICheckpointManager? checkpointManager, string? runId, IEnumerable 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 ResumeRunAsync(Workflow workflow, ICheckpointManager? checkpointManager, string? runId, CheckpointInfo fromCheckpoint, IEnumerable 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); } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessExecutionOptions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessExecutionOptions.cs new file mode 100644 index 0000000000..bc0eb59463 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessExecutionOptions.cs @@ -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; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs index 6d7a4bd691..9c100ecbbf 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs @@ -21,21 +21,46 @@ namespace Microsoft.Agents.AI.Workflows.InProc; /// scenarios where workflow execution does not require executor distribution. internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle { - public InProcessRunner(Workflow workflow, ICheckpointManager? checkpointManager, string? runId = null, object? workflowOwnership = null, bool subworkflow = false, IEnumerable? knownValidInputTypes = null) + public static InProcessRunner CreateTopLevelRunner(Workflow workflow, ICheckpointManager? checkpointManager, string? runId = null, bool enableConcurrentRuns = false, IEnumerable? 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? 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? 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); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs index f707c72be0..874464812e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs @@ -32,7 +32,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext private readonly ConcurrentDictionary> _executors = new(); private readonly ConcurrentQueue> _queuedExternalDeliveries = new(); - private readonly ConcurrentQueue _joinedSubworkflowRunners = new(); + private readonly ConcurrentDictionary _joinedSubworkflowRunners = new(); private readonly ConcurrentDictionary _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 AdvanceAsync(CancellationToken cancellationToken = default) { @@ -260,6 +273,10 @@ internal sealed class InProcessRunnerContext : IRunnerContext public ValueTask ReadStateAsync(string key, string? scopeName = null, CancellationToken cancellationToken = default) => RunnerContext.StateManager.ReadStateAsync(ExecutorId, scopeName, key); + [return: NotNull] + public ValueTask ReadOrInitStateAsync(string key, Func initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default) + => RunnerContext.StateManager.ReadOrInitStateAsync(ExecutorId, scopeName, key, initialStateFactory); + public ValueTask> 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? 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 JoinedSubworkflowRunners => this._joinedSubworkflowRunners; + public IEnumerable JoinedSubworkflowRunners => this._joinedSubworkflowRunners.Values; - public ValueTask AttachSuperstepAsync(ISuperStepRunner superStepRunner, CancellationToken cancellationToken = default) + public ValueTask 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 DetachSuperstepAsync(string joinId) => new(this._joinedSubworkflowRunners.TryRemove(joinId, out _)); + ValueTask ISuperStepJoinContext.ForwardWorkflowEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken) => this.AddEventAsync(workflowEvent, cancellationToken); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProcessExecution.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProcessExecution.cs index 831d4f5b29..7f736e58f3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProcessExecution.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProcessExecution.cs @@ -23,6 +23,11 @@ public static class InProcessExecution /// public static InProcessExecutionEnvironment OffThread { get; } = new(ExecutionMode.OffThread); + /// + /// Gets an execution environment that enables concurrent, off-thread in-process execution. + /// + public static InProcessExecutionEnvironment Concurrent { get; } = new(ExecutionMode.OffThread, enableConcurrentRuns: true); + /// /// 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 diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs index 61958cfbf9..d96f9319f4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs @@ -16,8 +16,9 @@ public class ReflectingExecutor< ] TExecutor > : Executor where TExecutor : ReflectingExecutor { - /// - protected ReflectingExecutor(string id, ExecutorOptions? options = null) : base(id, options) + /// + protected ReflectingExecutor(string id, ExecutorOptions? options = null, bool declareCrossRunShareable = false) + : base(id, options, declareCrossRunShareable) { } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/RoundRobinGroupChatManager.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/RoundRobinGroupChatManager.cs new file mode 100644 index 0000000000..8f11fe7ed6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/RoundRobinGroupChatManager.cs @@ -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; + +/// +/// Provides a that selects agents in a round-robin fashion. +/// +public class RoundRobinGroupChatManager : GroupChatManager +{ + private readonly IReadOnlyList _agents; + private readonly Func, CancellationToken, ValueTask>? _shouldTerminateFunc; + private int _nextIndex; + + /// + /// Initializes a new instance of the class. + /// + /// The agents to be managed as part of this workflow. + /// + /// 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. + /// + public RoundRobinGroupChatManager( + IReadOnlyList agents, + Func, CancellationToken, ValueTask>? shouldTerminateFunc = null) + { + Throw.IfNullOrEmpty(agents); + foreach (var agent in agents) + { + Throw.IfNull(agent, nameof(agents)); + } + + this._agents = agents; + this._shouldTerminateFunc = shouldTerminateFunc; + } + + /// + protected internal override ValueTask SelectNextAgentAsync( + IReadOnlyList history, CancellationToken cancellationToken = default) + { + AIAgent nextAgent = this._agents[this._nextIndex]; + + this._nextIndex = (this._nextIndex + 1) % this._agents.Count; + + return new ValueTask(nextAgent); + } + + /// + protected internal override async ValueTask ShouldTerminateAsync( + IReadOnlyList 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); + } + + /// + protected internal override void Reset() + { + base.Reset(); + this._nextIndex = 0; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AgentRunStreamingExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AgentRunStreamingExecutor.cs new file mode 100644 index 0000000000..ea80f646f0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AgentRunStreamingExecutor.cs @@ -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; + +/// +/// Executor that runs the agent and forwards all messages, input and output, to the next executor. +/// +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 messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default) + { + List? roleChanged = messages.ChangeAssistantToUserForOtherParticipants(agent.DisplayName); + + List 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 result = includeInputInOutput ? [.. messages] : []; + result.AddRange(updates.ToAgentRunResponse().Messages); + + await context.SendMessageAsync(result, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public new ValueTask ResetAsync() => base.ResetAsync(); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/ChatForwardingExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/ChatForwardingExecutor.cs new file mode 100644 index 0000000000..b395dd4216 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/ChatForwardingExecutor.cs @@ -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; + +/// Executor that forwards all messages. +internal sealed class ChatForwardingExecutor(string id) : Executor(id, declareCrossRunShareable: true), IResettableExecutor +{ + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => + routeBuilder + .AddHandler((message, context, cancellationToken) => context.SendMessageAsync(new ChatMessage(ChatRole.User, message), cancellationToken: cancellationToken)) + .AddHandler((message, context, cancellationToken) => context.SendMessageAsync(message, cancellationToken: cancellationToken)) + .AddHandler>((messages, context, cancellationToken) => context.SendMessageAsync(messages, cancellationToken: cancellationToken)) + .AddHandler((turnToken, context, cancellationToken) => context.SendMessageAsync(turnToken, cancellationToken: cancellationToken)); + + public ValueTask ResetAsync() => default; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/CollectChatMessagesExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/CollectChatMessagesExecutor.cs new file mode 100644 index 0000000000..8653dfbab1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/CollectChatMessagesExecutor.cs @@ -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; + +/// +/// Provides an executor that batches received chat messages that it then releases when +/// receiving a . +/// +internal sealed class CollectChatMessagesExecutor(string id) : ChatProtocolExecutor(id), IResettableExecutor +{ + /// + protected override ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default) + => context.SendMessageAsync(messages, cancellationToken: cancellationToken); + + ValueTask IResettableExecutor.ResetAsync() => this.ResetAsync(); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/ConcurrentEndExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/ConcurrentEndExecutor.cs new file mode 100644 index 0000000000..7f509ef9fe --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/ConcurrentEndExecutor.cs @@ -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; + +/// +/// 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. +/// +internal sealed class ConcurrentEndExecutor : Executor, IResettableExecutor +{ + private readonly int _expectedInputs; + private readonly Func>, List> _aggregator; + private List> _allResults; + private int _remaining; + + public ConcurrentEndExecutor(int expectedInputs, Func>, List> aggregator) : base("ConcurrentEnd") + { + this._expectedInputs = expectedInputs; + this._aggregator = Throw.IfNull(aggregator); + + this._allResults = new List>(expectedInputs); + this._remaining = expectedInputs; + } + + private void Reset() + { + this._allResults = new List>(this._expectedInputs); + this._remaining = this._expectedInputs; + } + + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => + routeBuilder.AddHandler>(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>(this._expectedInputs); + await context.YieldOutputAsync(this._aggregator(results), cancellationToken).ConfigureAwait(false); + } + }); + + public ValueTask ResetAsync() + { + this.Reset(); + return default; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/GroupChatHost.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/GroupChatHost.cs new file mode 100644 index 0000000000..16f749d5a3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/GroupChatHost.cs @@ -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 agentMap, + Func, GroupChatManager> managerFactory) : Executor(id), IResettableExecutor +{ + private readonly AIAgent[] _agents = agents; + private readonly Dictionary _agentMap = agentMap; + private readonly Func, GroupChatManager> _managerFactory = managerFactory; + private readonly List _pendingMessages = []; + + private GroupChatManager? _manager; + + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => routeBuilder + .AddHandler((message, context, _) => this._pendingMessages.Add(new(ChatRole.User, message))) + .AddHandler((message, context, _) => this._pendingMessages.Add(message)) + .AddHandler>((messages, _, __) => this._pendingMessages.AddRange(messages)) + .AddHandler((messages, _, __) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed + .AddHandler>((messages, _, __) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed + .AddHandler(async (token, context, cancellationToken) => + { + List 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; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs new file mode 100644 index 0000000000..53f8fe3cfa --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs @@ -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; + +/// Executor used to represent an agent in a handoffs workflow, responding to events. +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 _handoffFunctionNames = []; + private ChatClientAgentRunOptions? _agentOptions; + + public void Initialize( + WorkflowBuilder builder, + Executor end, + Dictionary executors, + HashSet 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(state => state?.InvokedHandoff == handoffFunc.Name, executors[handoff.Target.Id]); + } + } + + sb.WithDefault(end); + }); + + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => + routeBuilder.AddHandler(async (handoffState, context, cancellationToken) => + { + string? requestedHandoff = null; + List updates = []; + List allMessages = handoffState.Messages; + + List? 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; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs new file mode 100644 index 0000000000..cc4d87d21a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs @@ -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 Messages); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffTarget.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffTarget.cs new file mode 100644 index 0000000000..0abe238133 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffTarget.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Workflows.Specialized; + +/// Describes a handoff to a specific target . +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(); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs new file mode 100644 index 0000000000..1d825be665 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Workflows.Specialized; + +/// Executor used at the end of a handoff workflow to raise a final completed event. +internal sealed class HandoffsEndExecutor() : Executor("HandoffEnd"), IResettableExecutor +{ + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => + routeBuilder.AddHandler((handoff, context, cancellationToken) => + context.YieldOutputAsync(handoff.Messages, cancellationToken)); + + public ValueTask ResetAsync() => default; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs new file mode 100644 index 0000000000..e7f6789edf --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs @@ -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; + +/// Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token. +internal sealed class HandoffsStartExecutor() : ChatProtocolExecutor("HandoffStart", DefaultOptions), IResettableExecutor +{ + private static ChatProtocolExecutorOptions DefaultOptions => new() + { + StringMessageChatRole = ChatRole.User + }; + + protected override ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default) + => context.SendMessageAsync(new HandoffState(new(emitEvents), null, messages), cancellationToken: cancellationToken); + + public new ValueTask ResetAsync() => base.ResetAsync(); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/OutputMessagesExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/OutputMessagesExecutor.cs new file mode 100644 index 0000000000..a9d1005c0d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/OutputMessagesExecutor.cs @@ -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 +{ + /// + /// Provides an executor that batches received chat messages that it then publishes as the final result + /// when receiving a . + /// + internal sealed class OutputMessagesExecutor() : ChatProtocolExecutor("OutputMessages"), IResettableExecutor + { + protected override ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default) + => context.YieldOutputAsync(messages, cancellationToken); + + ValueTask IResettableExecutor.ResetAsync() => default; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/WorkflowHostExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/WorkflowHostExecutor.cs index 4bcffafef9..409f751107 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/WorkflowHostExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/WorkflowHostExecutor.cs @@ -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; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs new file mode 100644 index 0000000000..344134369d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs @@ -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; + +/// +/// Provides a base class for executors that maintain and manage state across multiple message handling operations. +/// +/// The type of state associated with this Executor. +public abstract class StatefulExecutor : Executor +{ + private readonly Func _initialStateFactory; + + private TState? _stateCache; + + /// + /// Initializes the executor with a unique id and an initial value for the state. + /// + /// The unique identifier for this executor instance. Cannot be null or empty. + /// A factory to initialize the state value to be used by the executor. + /// Optional configuration settings for the executor. If null, default options are used. + /// true to declare that the executor's state can be shared across multiple runs; otherwise, false. + protected StatefulExecutor(string id, + Func initialStateFactory, + StatefulExecutorOptions? options = null, + bool declareCrossRunShareable = false) + : base(id, options ?? new StatefulExecutorOptions(), declareCrossRunShareable) + { + this.Options = (StatefulExecutorOptions)base.Options; + this._initialStateFactory = Throw.IfNull(initialStateFactory); + } + + /// + protected new StatefulExecutorOptions Options { get; } + + private string DefaultStateKey => $"{this.GetType().Name}.State"; + + /// + /// Gets the key used to identify the executor's state. + /// + protected string StateKey => this.Options.StateKey ?? this.DefaultStateKey; + + /// + /// Reads the state associated with this executor. If it is not initialized, it will be set to the initial state. + /// + /// The workflow context in which the executor executes. + /// Ignore the cached value, if any. State is not cached when running in Cross-Run Shareable + /// mode. + /// The to monitor for cancellation requests. + /// The default is . + /// + protected async ValueTask 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; + } + + /// + /// Queues up an update to the executor's state. + /// + /// The new value of state. + /// The workflow context in which the executor executes. + /// The to monitor for cancellation requests. + /// The default is . + /// + 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); + } + + /// + /// Invokes an asynchronous operation that reads, updates, and persists workflow state associated with the specified + /// key. + /// + /// A delegate that receives the current state, workflow context, and cancellation token, + /// and returns the updated state asynchronously. + /// The workflow context in which the executor executes. + /// Ignore the cached value, if any. State is not cached when running in Cross-Run Shareable + /// mode. + /// The to monitor for cancellation requests. + /// The default is . + /// A ValueTask that represents the asynchronous operation. + protected async ValueTask InvokeWithStateAsync( + Func> 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); + } + } + + /// + protected ValueTask ResetAsync() + { + this._stateCache = this._initialStateFactory(); + + return default; + } +} + +/// +/// Provides a simple executor implementation that uses a single message handler function to process incoming messages, +/// and maintain state across invocations. +/// +/// The type of state associated with this Executor. +/// The type of input message. +/// A unique identifier for the executor. +/// A factory to initialize the state value to be used by the executor. +/// Configuration options for the executor. If null, default options will be used. +/// Declare that this executor may be used simultaneously by multiple runs safely. +public abstract class StatefulExecutor(string id, Func initialStateFactory, StatefulExecutorOptions? options = null, bool declareCrossRunShareable = false) + : StatefulExecutor(id, initialStateFactory, options, declareCrossRunShareable), IMessageHandler +{ + /// + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => + routeBuilder.AddHandler(this.HandleAsync); + + /// + public abstract ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default); +} + +/// +/// Provides a simple executor implementation that uses a single message handler function to process incoming messages, +/// and maintain state across invocations. +/// +/// The type of state associated with this Executor. +/// The type of input message. +/// The type of output message. +/// A unique identifier for the executor. +/// A factory to initialize the state value to be used by the executor. +/// Configuration options for the executor. If null, default options will be used. +/// Declare that this executor may be used simultaneously by multiple runs safely. +public abstract class StatefulExecutor(string id, Func initialStateFactory, StatefulExecutorOptions? options = null, bool declareCrossRunShareable = false) + : StatefulExecutor(id, initialStateFactory, options, declareCrossRunShareable), IMessageHandler +{ + /// + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => + routeBuilder.AddHandler(this.HandleAsync); + + /// + public abstract ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutorOptions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutorOptions.cs new file mode 100644 index 0000000000..4ff569374f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutorOptions.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// . +/// +public class StatefulExecutorOptions : ExecutorOptions +{ + /// + /// Gets or sets the unique key that identifies the executor's state. If not provided, will default to + /// `{ExecutorType}.State`. + /// + public string? StateKey { get; set; } + + /// + /// 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. + /// + public string? ScopeName { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs index 4964332148..8205d40b20 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs @@ -66,6 +66,11 @@ public class Workflow /// public string? Description { get; internal init; } + internal bool AllowConcurrent => this.Registrations.Values.All(registration => registration.SupportsConcurrent); + + internal IEnumerable NonConcurrentExecutorIds => + this.Registrations.Values.Where(r => !r.SupportsConcurrent).Select(r => r.Id); + /// /// Initializes a new instance of the 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 => "", + _ => $"{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); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs index 8fa289d7ea..54b36a8c64 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs @@ -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 _assignedRunIds = []; - public WorkflowHostAgent(Workflow> workflow, string? id = null, string? name = null, CheckpointManager? checkpointManager = null) + public WorkflowHostAgent(Workflow> 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 UpdateThreadAsync(IEnumerable messages, AgentThread? thread = null, CancellationToken cancellationToken = default) { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostingExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostingExtensions.cs index b41034b317..e3a357a134 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostingExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostingExtensions.cs @@ -15,23 +15,45 @@ public static class WorkflowHostingExtensions /// /// Convert a workflow with the appropriate primary input type to an . /// - /// - /// - /// + /// The workflow to be hosted by the resulting + /// A unique id for the hosting . + /// A name for the hosting . + /// A description for the hosting . + /// A to enable persistence of run state. + /// Specify the execution environment to use when running the workflows. See + /// , and + /// for the in-process environments. /// - public static AIAgent AsAgent(this Workflow> workflow, string? id = null, string? name = null) + public static AIAgent AsAgent( + this Workflow> 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); } /// /// Convert a workflow with the appropriate primary input type to an . /// - /// - /// - /// + /// The workflow to be hosted by the resulting + /// A unique id for the hosting . + /// A name for the hosting . + /// /// A description for the hosting . + /// A to enable persistence of run state. + /// Specify the execution environment to use when running the workflows. See + /// , and + /// for the in-process environments. /// - public static async ValueTask AsAgentAsync(this Workflow workflow, string? id = null, string? name = null) + public static async ValueTask AsAgentAsync( + this Workflow workflow, + string? id = null, + string? name = null, + string? description = null, + CheckpointManager? checkpointManager = null, + IWorkflowExecutionEnvironment? executionEnvironment = null) { Workflow>? maybeTyped = await workflow.TryPromoteAsync>() .ConfigureAwait(false); @@ -41,7 +63,7 @@ public static class WorkflowHostingExtensions throw new InvalidOperationException("Cannot host a workflow that does not accept List 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) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowOutputEvent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowOutputEvent.cs index 64c7b5be4c..760f2ae029 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowOutputEvent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowOutputEvent.cs @@ -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 /// true if the underlying data is assignable to type T; otherwise, false. public bool Is() => this.IsType(typeof(T)); + /// + /// Determines whether the underlying data is of the specified type or a derived type, and + /// returns it as that type if it is. + /// + /// The type to compare with the type of the underlying data. + /// true if the underlying data is assignable to type T; otherwise, false. + public bool Is([NotNullWhen(true)] out T? maybeValue) + { + if (this.Data is T value) + { + maybeValue = value; + return true; + } + + maybeValue = default; + return false; + } + /// /// Determines whether the underlying data is of the specified type or a derived type. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowThread.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowThread.cs index 61dc0ab337..160785a49a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowThread.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowThread.cs @@ -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(serializedThread); @@ -101,23 +105,25 @@ internal sealed class WorkflowThread : AgentThread if (this.LastCheckpoint is not null) { Checkpointed 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 diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs index f64c1a6b80..752bb4bac7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs @@ -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. diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs index efd1c96f88..a96f130a6c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs @@ -57,24 +57,24 @@ public class AgentWorkflowBuilderTests { Assert.Throws("managerFactory", () => AgentWorkflowBuilder.CreateGroupChatBuilderWith(null!)); - var groupChat = AgentWorkflowBuilder.CreateGroupChatBuilderWith(_ => new AgentWorkflowBuilder.RoundRobinGroupChatManager([new DoubleEchoAgent("a1")])); + var groupChat = AgentWorkflowBuilder.CreateGroupChatBuilderWith(_ => new RoundRobinGroupChatManager([new DoubleEchoAgent("a1")])); Assert.NotNull(groupChat); Assert.Throws("agents", () => groupChat.AddParticipants(null!)); Assert.Throws("agents", () => groupChat.AddParticipants([null!])); Assert.Throws("agents", () => groupChat.AddParticipants(new DoubleEchoAgent("a1"), null!)); - Assert.Throws("agents", () => new AgentWorkflowBuilder.RoundRobinGroupChatManager(null!)); + Assert.Throws("agents", () => new RoundRobinGroupChatManager(null!)); } [Fact] public void GroupChatManager_MaximumIterationCount_Invalid_Throws() { - var manager = new AgentWorkflowBuilder.RoundRobinGroupChatManager([new DoubleEchoAgent("a1")]); + var manager = new RoundRobinGroupChatManager([new DoubleEchoAgent("a1")]); const int DefaultMaxIterations = 40; Assert.Equal(DefaultMaxIterations, manager.MaximumIterationCount); - Assert.Throws("value", () => manager.MaximumIterationCount = 0); - Assert.Throws("value", () => manager.MaximumIterationCount = -1); + Assert.Throws("value", void () => manager.MaximumIterationCount = 0); + Assert.Throws("value", void () => manager.MaximumIterationCount = -1); Assert.Equal(DefaultMaxIterations, manager.MaximumIterationCount); manager.MaximumIterationCount = 30; @@ -344,7 +344,7 @@ public class AgentWorkflowBuilderTests public async Task BuildGroupChat_AgentsRunInOrderAsync(int maxIterations) { const int NumAgents = 3; - var workflow = AgentWorkflowBuilder.CreateGroupChatBuilderWith(agents => new AgentWorkflowBuilder.RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxIterations }) + var workflow = AgentWorkflowBuilder.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxIterations }) .AddParticipants(new DoubleEchoAgent("agent1"), new DoubleEchoAgent("agent2")) .AddParticipants(new DoubleEchoAgent("agent3")) .Build(); @@ -386,7 +386,7 @@ public class AgentWorkflowBuilderTests { StringBuilder sb = new(); - await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, input); + await using StreamingRun run = await InProcessExecution.Lockstep.StreamAsync(workflow, input); await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); WorkflowOutputEvent? output = null; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ChatProtocolExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ChatProtocolExecutorTests.cs index 7fa7d42316..a689baee51 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ChatProtocolExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ChatProtocolExecutorTests.cs @@ -40,46 +40,12 @@ public class ChatProtocolExecutorTests } } - private sealed class TestWorkflowContext : IWorkflowContext - { - public List SentMessages { get; } = []; - - public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default) => - default; - - public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default) => - default; - - public ValueTask RequestHaltAsync() => - default; - - public ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default) => - default; - - public ValueTask QueueStateUpdateAsync(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default) => - default; - - public ValueTask ReadStateAsync(string key, string? scopeName = null, CancellationToken cancellationToken = default) => - default; - - public ValueTask> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default) => - default; - - public ValueTask SendMessageAsync(object message, string? targetId = null, CancellationToken cancellationToken = default) - { - this.SentMessages.Add(message); - return default; - } - - public IReadOnlyDictionary? TraceContext => null; - } - [Fact] public async Task ChatProtocolExecutor_Handles_ListOfChatMessagesAsync() { // Arrange var executor = new TestChatProtocolExecutor(); - var context = new TestWorkflowContext(); + var context = new TestWorkflowContext(executor.Id); List messages = [ @@ -103,7 +69,7 @@ public class ChatProtocolExecutorTests { // Arrange var executor = new TestChatProtocolExecutor(); - var context = new TestWorkflowContext(); + var context = new TestWorkflowContext(executor.Id); ChatMessage[] messages = [ @@ -129,7 +95,7 @@ public class ChatProtocolExecutorTests { // Arrange var executor = new TestChatProtocolExecutor(); - var context = new TestWorkflowContext(); + var context = new TestWorkflowContext(executor.Id); var message = new ChatMessage(ChatRole.User, "Single message"); @@ -147,7 +113,7 @@ public class ChatProtocolExecutorTests public async Task ChatProtocolExecutor_AccumulatesAndClearsMessagesPerTurnAsync() { var executor = new TestChatProtocolExecutor(); - var context = new TestWorkflowContext(); + var context = new TestWorkflowContext(executor.Id); // Send multiple message batches before taking a turn await executor.ExecuteAsync(new ChatMessage(ChatRole.User, "Message 1"), new TypeId(typeof(ChatMessage)), context); @@ -186,7 +152,7 @@ public class ChatProtocolExecutorTests { StringMessageChatRole = ChatRole.User }); - var context = new TestWorkflowContext(); + var context = new TestWorkflowContext(executor.Id); await executor.ExecuteAsync("String message", new TypeId(typeof(string)), context); await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context); @@ -200,7 +166,7 @@ public class ChatProtocolExecutorTests public async Task ChatProtocolExecutor_EmptyCollection_HandledCorrectlyAsync() { var executor = new TestChatProtocolExecutor(); - var context = new TestWorkflowContext(); + var context = new TestWorkflowContext(executor.Id); await executor.ExecuteAsync(new List(), new TypeId(typeof(List)), context); await executor.ExecuteAsync(Array.Empty(), new TypeId(typeof(ChatMessage[])), context); @@ -216,7 +182,7 @@ public class ChatProtocolExecutorTests public async Task ChatProtocolExecutor_RoutesCollectionTypesAsync(Type collectionType) { var executor = new TestChatProtocolExecutor(); - var context = new TestWorkflowContext(); + var context = new TestWorkflowContext(executor.Id); var sourceMessages = new[] { new ChatMessage(ChatRole.User, "Test message") }; object messagesToSend = collectionType == typeof(List) ? sourceMessages.ToList() : sourceMessages; @@ -232,7 +198,7 @@ public class ChatProtocolExecutorTests public async Task ChatProtocolExecutor_MultipleTurns_EachTurnProcessesSeparatelyAsync() { var executor = new TestChatProtocolExecutor(); - var context = new TestWorkflowContext(); + var context = new TestWorkflowContext(executor.Id); await executor.ExecuteAsync(new List { new(ChatRole.User, "Turn 1") }, new TypeId(typeof(List)), context); await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context); @@ -252,7 +218,7 @@ public class ChatProtocolExecutorTests public async Task ChatProtocolExecutor_InitialWorkflowMessages_RoutedCorrectlyAsync() { var executor = new TestChatProtocolExecutor(); - var context = new TestWorkflowContext(); + var context = new TestWorkflowContext(executor.Id); List initialMessages = [new ChatMessage(ChatRole.User, "Kick off the workflow")]; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ExecutionExtensions.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ExecutionExtensions.cs index 747461323d..78fae79c87 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ExecutionExtensions.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ExecutionExtensions.cs @@ -1,20 +1,20 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using Microsoft.Agents.AI.Workflows.InProc; namespace Microsoft.Agents.AI.Workflows.UnitTests; internal static class ExecutionExtensions { - public static InProcessExecutionEnvironment GetEnvironment(this ExecutionMode executionMode) + public static IWorkflowExecutionEnvironment ToWorkflowExecutionEnvironment(this ExecutionEnvironment environment) { - return executionMode switch + return environment switch { - ExecutionMode.OffThread => InProcessExecution.OffThread, - ExecutionMode.Lockstep => InProcessExecution.Lockstep, - ExecutionMode.Subworkflow => throw new NotSupportedException(), - _ => throw new InvalidOperationException($"Unknown execution mode {executionMode}") + ExecutionEnvironment.InProcess_OffThread => InProcessExecution.OffThread, + ExecutionEnvironment.InProcess_Lockstep => InProcessExecution.Lockstep, + ExecutionEnvironment.InProcess_Concurrent => InProcessExecution.Concurrent, + + _ => throw new InvalidOperationException($"Unknown execution environment {environment}") }; } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj index 22fd534b81..bd9bc57915 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj @@ -1,7 +1,8 @@ - + $(ProjectsTargetFrameworks) + $(NoWarn);MEAI001 diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs index 7ec83b4d03..72dfca59e4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs @@ -5,9 +5,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.AI.Workflows.InProc; using Microsoft.Agents.AI.Workflows.Reflection; -using Microsoft.Agents.AI.Workflows.UnitTests; namespace Microsoft.Agents.AI.Workflows.Sample; @@ -27,11 +25,9 @@ internal static class Step1EntryPoint } } - public static async ValueTask RunAsync(TextWriter writer, ExecutionMode executionMode) + public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment) { - InProcessExecutionEnvironment env = executionMode.GetEnvironment(); - - StreamingRun run = await env.StreamAsync(WorkflowInstance, "Hello, World!").ConfigureAwait(false); + StreamingRun run = await environment.StreamAsync(WorkflowInstance, "Hello, World!").ConfigureAwait(false); await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { @@ -43,13 +39,13 @@ internal static class Step1EntryPoint } } -internal sealed class UppercaseExecutor() : ReflectingExecutor("UppercaseExecutor"), IMessageHandler +internal sealed class UppercaseExecutor() : ReflectingExecutor("UppercaseExecutor", declareCrossRunShareable: true), IMessageHandler { public async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) => message.ToUpperInvariant(); } -internal sealed class ReverseTextExecutor() : ReflectingExecutor("ReverseTextExecutor"), IMessageHandler +internal sealed class ReverseTextExecutor() : ReflectingExecutor("ReverseTextExecutor", declareCrossRunShareable: true), IMessageHandler { public async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) { diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01a_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01a_Simple_Workflow_Sequential.cs index 04757b9350..ffa798126e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01a_Simple_Workflow_Sequential.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01a_Simple_Workflow_Sequential.cs @@ -2,18 +2,15 @@ using System.IO; using System.Threading.Tasks; -using Microsoft.Agents.AI.Workflows.InProc; -using Microsoft.Agents.AI.Workflows.UnitTests; using static Microsoft.Agents.AI.Workflows.Sample.Step1EntryPoint; namespace Microsoft.Agents.AI.Workflows.Sample; internal static class Step1aEntryPoint { - public static async ValueTask RunAsync(TextWriter writer, ExecutionMode executionMode) + public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment) { - InProcessExecutionEnvironment env = executionMode.GetEnvironment(); - Run run = await env.RunAsync(WorkflowInstance, "Hello, World!").ConfigureAwait(false); + Run run = await environment.RunAsync(WorkflowInstance, "Hello, World!").ConfigureAwait(false); Assert.Equal(RunStatus.Idle, await run.GetStatusAsync()); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs index 5b9a0712ba..50e65dc55c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs @@ -5,9 +5,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.AI.Workflows.InProc; using Microsoft.Agents.AI.Workflows.Reflection; -using Microsoft.Agents.AI.Workflows.UnitTests; namespace Microsoft.Agents.AI.Workflows.Sample; @@ -31,10 +29,9 @@ internal static class Step2EntryPoint } } - public static async ValueTask RunAsync(TextWriter writer, ExecutionMode executionMode, string input = "This is a spam message.") + public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment, string input = "This is a spam message.") { - InProcessExecutionEnvironment env = executionMode.GetEnvironment(); - StreamingRun handle = await env.StreamAsync(WorkflowInstance, input).ConfigureAwait(false); + StreamingRun handle = await environment.StreamAsync(WorkflowInstance, input).ConfigureAwait(false); await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) { switch (evt) @@ -55,13 +52,13 @@ internal static class Step2EntryPoint } internal sealed class DetectSpamExecutor(string id, params string[] spamKeywords) : - ReflectingExecutor(id), IMessageHandler + ReflectingExecutor(id, declareCrossRunShareable: true), IMessageHandler { public async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) => spamKeywords.Any(keyword => message.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) >= 0); } -internal sealed class RespondToMessageExecutor(string id) : ReflectingExecutor(id), IMessageHandler +internal sealed class RespondToMessageExecutor(string id) : ReflectingExecutor(id, declareCrossRunShareable: true), IMessageHandler { public const string ActionResult = "Message processed successfully."; @@ -80,7 +77,7 @@ internal sealed class RespondToMessageExecutor(string id) : ReflectingExecutor(id), IMessageHandler +internal sealed class RemoveSpamExecutor(string id) : ReflectingExecutor(id, declareCrossRunShareable: true), IMessageHandler { public const string ActionResult = "Spam message removed."; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs index 73129d9fdc..62ba2a8a68 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs @@ -4,9 +4,7 @@ using System; using System.IO; using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.AI.Workflows.InProc; using Microsoft.Agents.AI.Workflows.Reflection; -using Microsoft.Agents.AI.Workflows.UnitTests; namespace Microsoft.Agents.AI.Workflows.Sample; @@ -27,10 +25,9 @@ internal static class Step3EntryPoint } } - public static async ValueTask RunAsync(TextWriter writer, ExecutionMode executionMode) + public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment) { - InProcessExecutionEnvironment env = executionMode.GetEnvironment(); - StreamingRun run = await env.StreamAsync(WorkflowInstance, NumberSignal.Init).ConfigureAwait(false); + StreamingRun run = await environment.StreamAsync(WorkflowInstance, NumberSignal.Init).ConfigureAwait(false); await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { @@ -51,6 +48,16 @@ internal static class Step3EntryPoint } } +internal sealed record TryCount(int Tries); + +internal sealed record NumberBounds(int LowerBound, int UpperBound) +{ + public int CurrGuess => (this.LowerBound + this.UpperBound) / 2; + + public NumberBounds ForAboveHint() => this with { UpperBound = this.CurrGuess - 1 }; + public NumberBounds ForBelowHint() => this with { LowerBound = this.CurrGuess + 1 }; +} + internal enum NumberSignal { Init, @@ -61,74 +68,65 @@ internal enum NumberSignal internal sealed class GuessNumberExecutor : ReflectingExecutor, IMessageHandler { - public int LowerBound { get; private set; } - public int UpperBound { get; private set; } + private readonly int _initialLowerBound; + private readonly int _initialUpperBound; - public GuessNumberExecutor(string id, int lowerBound, int upperBound) : base(id, new ExecutorOptions { AutoYieldOutputHandlerResultObject = false }) + public GuessNumberExecutor(string id, int lowerBound, int upperBound) : base(id, new ExecutorOptions { AutoYieldOutputHandlerResultObject = false }, declareCrossRunShareable: true) { - this.LowerBound = lowerBound; - this.UpperBound = upperBound; + if (lowerBound >= upperBound) + { + throw new ArgumentOutOfRangeException(nameof(lowerBound), "Lower bound must be less than upper bound."); + } + + this._initialLowerBound = lowerBound; + this._initialUpperBound = upperBound; } - private int NextGuess => (this.LowerBound + this.UpperBound) / 2; - - private int _currGuess = -1; public async ValueTask HandleAsync(NumberSignal message, IWorkflowContext context, CancellationToken cancellationToken = default) { + NumberBounds bounds = await context.ReadStateAsync(nameof(NumberBounds), cancellationToken: cancellationToken) + .ConfigureAwait(false) + ?? new NumberBounds(this._initialLowerBound, this._initialUpperBound); + switch (message) { case NumberSignal.Matched: - await context.YieldOutputAsync($"Guessed the number: {this._currGuess}", cancellationToken) + await context.YieldOutputAsync($"Guessed the number: {bounds.CurrGuess}", cancellationToken) .ConfigureAwait(false); break; case NumberSignal.Above: - this.UpperBound = this._currGuess - 1; + bounds = bounds.ForAboveHint(); break; case NumberSignal.Below: - this.LowerBound = this._currGuess + 1; + bounds = bounds.ForBelowHint(); break; } - this._currGuess = this.NextGuess; - return this._currGuess; + await context.QueueStateUpdateAsync(nameof(NumberBounds), bounds, cancellationToken: cancellationToken).ConfigureAwait(false); + + return bounds.CurrGuess; } } -internal sealed class JudgeExecutor : ReflectingExecutor, IMessageHandler, IResettableExecutor +internal sealed class JudgeExecutor : ReflectingExecutor, IMessageHandler { private readonly int _targetNumber; - internal int? Tries { get; private set; } - - public JudgeExecutor(string id, int targetNumber) : base(id) + public JudgeExecutor(string id, int targetNumber) : base(id, declareCrossRunShareable: true) { this._targetNumber = targetNumber; } public async ValueTask HandleAsync(int message, IWorkflowContext context, CancellationToken cancellationToken = default) { - this.Tries = this.Tries is int tries ? tries + 1 : 1; + // This works properly because the default when unset is 0, and we increment before use. + int tries = await context.ReadStateAsync("TryCount", cancellationToken: cancellationToken).ConfigureAwait(false) + 1; + await context.YieldOutputAsync(new TryCount(tries), cancellationToken); return message == this._targetNumber ? NumberSignal.Matched : message < this._targetNumber ? NumberSignal.Below : NumberSignal.Above; } - - protected internal override ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) - { - return context.QueueStateUpdateAsync("TryCount", this.Tries, cancellationToken: cancellationToken); - } - - protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) - { - this.Tries = await context.ReadStateAsync("TryCount", cancellationToken: cancellationToken).ConfigureAwait(false) ?? 0; - } - - public ValueTask ResetAsync() - { - this.Tries = null; - return default; - } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/04_Simple_Workflow_ExternalRequest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/04_Simple_Workflow_ExternalRequest.cs index e297e9da91..69d21600c4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/04_Simple_Workflow_ExternalRequest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/04_Simple_Workflow_ExternalRequest.cs @@ -4,8 +4,6 @@ using System; using System.Collections.Generic; using System.IO; using System.Threading.Tasks; -using Microsoft.Agents.AI.Workflows.InProc; -using Microsoft.Agents.AI.Workflows.UnitTests; namespace Microsoft.Agents.AI.Workflows.Sample; @@ -39,14 +37,13 @@ internal static class Step4EntryPoint } } - public static async ValueTask RunAsync(TextWriter writer, Func userGuessCallback, ExecutionMode executionMode) + public static async ValueTask RunAsync(TextWriter writer, Func userGuessCallback, IWorkflowExecutionEnvironment environment) { NumberSignal signal = NumberSignal.Init; string? prompt = UpdatePrompt(null, signal); Workflow workflow = WorkflowInstance; - InProcessExecutionEnvironment env = executionMode.GetEnvironment(); - StreamingRun handle = await env.StreamAsync(workflow, NumberSignal.Init).ConfigureAwait(false); + StreamingRun handle = await environment.StreamAsync(workflow, NumberSignal.Init).ConfigureAwait(false); List requests = []; await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) @@ -57,13 +54,15 @@ internal static class Step4EntryPoint switch (outputEvent.SourceId) { case JudgeId: - if (!outputEvent.Is()) + if (outputEvent.Is(out NumberSignal newSignal)) + { + prompt = UpdatePrompt(prompt, signal = newSignal); + } + else if (!outputEvent.Is()) { throw new InvalidOperationException($"Unexpected output type {outputEvent.Data!.GetType()}"); } - signal = outputEvent.As()!.Value; - prompt = UpdatePrompt(prompt, signal); break; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/05_Simple_Workflow_Checkpointing.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/05_Simple_Workflow_Checkpointing.cs index 4e6a137cdc..aab5fd0958 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/05_Simple_Workflow_Checkpointing.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/05_Simple_Workflow_Checkpointing.cs @@ -6,14 +6,12 @@ using System.IO; using System.Threading; using System.Threading.Tasks; using FluentAssertions; -using Microsoft.Agents.AI.Workflows.InProc; -using Microsoft.Agents.AI.Workflows.UnitTests; namespace Microsoft.Agents.AI.Workflows.Sample; internal static class Step5EntryPoint { - public static async ValueTask RunAsync(TextWriter writer, Func userGuessCallback, ExecutionMode executionMode, bool rehydrateToRestore = false, CheckpointManager? checkpointManager = null) + public static async ValueTask RunAsync(TextWriter writer, Func userGuessCallback, IWorkflowExecutionEnvironment environment, bool rehydrateToRestore = false, CheckpointManager? checkpointManager = null) { Dictionary checkpointedOutputs = []; @@ -24,10 +22,9 @@ internal static class Step5EntryPoint Workflow workflow = Step4EntryPoint.CreateWorkflowInstance(out JudgeExecutor judge); - InProcessExecutionEnvironment env = executionMode.GetEnvironment(); Checkpointed checkpointed = - await env.StreamAsync(workflow, NumberSignal.Init, checkpointManager) - .ConfigureAwait(false); + await environment.StreamAsync(workflow, NumberSignal.Init, checkpointManager) + .ConfigureAwait(false); List checkpoints = []; CancellationTokenSource cancellationSource = new(); @@ -37,7 +34,6 @@ internal static class Step5EntryPoint result.Should().BeNull(); checkpoints.Should().HaveCount(6, "we should have two checkpoints, one for each step"); - judge.Tries.Should().Be(2); CheckpointInfo targetCheckpoint = checkpoints[2]; @@ -46,8 +42,8 @@ internal static class Step5EntryPoint { await handle.DisposeAsync().ConfigureAwait(false); - checkpointed = await env.ResumeStreamAsync(workflow, targetCheckpoint, checkpointManager, runId: handle.RunId, cancellationToken: CancellationToken.None) - .ConfigureAwait(false); + checkpointed = await environment.ResumeStreamAsync(workflow, targetCheckpoint, checkpointManager, runId: handle.RunId, cancellationToken: CancellationToken.None) + .ConfigureAwait(false); handle = checkpointed.Run; } else @@ -57,8 +53,6 @@ internal static class Step5EntryPoint (signal, prompt) = checkpointedOutputs[targetCheckpoint]; - judge.Tries.Should().Be(1); - cancellationSource.Dispose(); cancellationSource = new(); @@ -66,7 +60,11 @@ internal static class Step5EntryPoint result = await RunStreamToHaltOrMaxStepAsync().ConfigureAwait(false); result.Should().NotBeNull(); - checkpoints.Should().HaveCount(6); + + // Depending on the timing of the response with respect to the underlying workflow + // we may end up with an extra superstep in between. + checkpoints.Should().HaveCountGreaterThanOrEqualTo(6) + .And.HaveCountLessThanOrEqualTo(7); cancellationSource.Dispose(); @@ -84,13 +82,16 @@ internal static class Step5EntryPoint switch (outputEvent.SourceId) { case Step4EntryPoint.JudgeId: - if (!outputEvent.Is()) + if (outputEvent.Is(out NumberSignal newSignal)) + { + prompt = Step4EntryPoint.UpdatePrompt(prompt, signal = newSignal); + } + // TODO: We should make some well-defined way to avoid this kind of + // if/elseif chain, because .Is() chains are slow + else if (!outputEvent.Is()) { throw new InvalidOperationException($"Unexpected output type {outputEvent.Data!.GetType()}"); } - - signal = outputEvent.As()!.Value; - prompt = Step4EntryPoint.UpdatePrompt(null, signal); break; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/06_GroupChat_Workflow.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/06_GroupChat_Workflow.cs index 3e6049f4ce..33a0fbc13f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/06_GroupChat_Workflow.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/06_GroupChat_Workflow.cs @@ -10,8 +10,6 @@ using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.AI.Workflows.InProc; -using Microsoft.Agents.AI.Workflows.UnitTests; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.Sample; @@ -20,16 +18,15 @@ internal static class Step6EntryPoint { public static Workflow CreateWorkflow(int maxTurns) => AgentWorkflowBuilder - .CreateGroupChatBuilderWith(agents => new AgentWorkflowBuilder.RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxTurns }) + .CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxTurns }) .AddParticipants(new HelloAgent(), new EchoAgent()) .Build(); - public static async ValueTask RunAsync(TextWriter writer, ExecutionMode executionMode, int maxSteps = 2) + public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment, int maxSteps = 2) { Workflow workflow = CreateWorkflow(maxSteps); - InProcessExecutionEnvironment env = executionMode.GetEnvironment(); - StreamingRun run = await env.StreamAsync(workflow, Array.Empty()) + StreamingRun run = await environment.StreamAsync(workflow, Array.Empty()) .ConfigureAwait(false); await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/08_Subworkflow_Simple.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/08_Subworkflow_Simple.cs index 35b739fa44..8ec7978606 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/08_Subworkflow_Simple.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/08_Subworkflow_Simple.cs @@ -7,15 +7,13 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using FluentAssertions; -using Microsoft.Agents.AI.Workflows.InProc; -using Microsoft.Agents.AI.Workflows.UnitTests; namespace Microsoft.Agents.AI.Workflows.Sample; internal sealed record class TextProcessingRequest(string Text, string TaskId); internal sealed record class TextProcessingResult(string TaskId, string Text, int WordCount, int ChatCount); -internal sealed class AllTasksCompletedEvent(IEnumerable results) : WorkflowEvent(results); +//internal sealed class AllTasksCompletedEvent(IEnumerable results) : WorkflowEvent(results); internal static class Step8EntryPoint { @@ -28,31 +26,39 @@ internal static class Step8EntryPoint " Spaces around text ", ]; - public static async ValueTask> RunAsync(TextWriter writer, ExecutionMode executionMode, List textsToProcess) + public static async ValueTask> RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment, List textsToProcess) { Func processTextAsyncFunc = ProcessTextAsync; - ExecutorIsh processText = processTextAsyncFunc.AsExecutor("TextProcessor"); + ExecutorIsh processText = processTextAsyncFunc.AsExecutor("TextProcessor", threadsafe: true); Workflow subWorkflow = new WorkflowBuilder(processText).WithOutputFrom(processText).Build(); ExecutorIsh textProcessor = subWorkflow.ConfigureSubWorkflow("TextProcessor"); - TextProcessingOrchestrator orchestrator = new(); + Func> createOrchestrator = (id, _) => new(new TextProcessingOrchestrator(id)); + var orchestrator = createOrchestrator.ConfigureFactory(); Workflow workflow = new WorkflowBuilder(orchestrator) .AddEdge(orchestrator, textProcessor) .AddEdge(textProcessor, orchestrator) + .WithOutputFrom(orchestrator) .Build(); - InProcessExecutionEnvironment env = executionMode.GetEnvironment(); - Run workflowRun = await env.RunAsync(workflow, textsToProcess); + Run workflowRun = await environment.RunAsync(workflow, textsToProcess); RunStatus status = await workflowRun.GetStatusAsync(); status.Should().Be(RunStatus.Idle); - List results = orchestrator.Results; + WorkflowOutputEvent? maybeOutput = workflowRun.OutgoingEvents.OfType() + .SingleOrDefault(); + + maybeOutput.Should().NotBeNull("the workflow should have produced an output event"); + List? maybeResults = maybeOutput.As>(); + + maybeResults.Should().NotBeNull("the output event should contain the results"); + List results = maybeResults; + results.Sort((left, right) => StringComparer.Ordinal.Compare(left.TaskId, right.TaskId)); - // This is a placeholder for the entry point of Step 8. return results; } @@ -70,10 +76,19 @@ internal static class Step8EntryPoint return context.YieldOutputAsync(new TextProcessingResult(request.TaskId, request.Text, wordCount, charCount), cancellationToken); } - private sealed class TextProcessingOrchestrator() : Executor("TextOrchestrator") + private sealed class TextProcessingOrchestrator(string id) + : StatefulExecutor(id, () => new(), declareCrossRunShareable: false) { - public List Results { get; } = new(); - public HashSet PendingTaskIds { get; } = new(); + internal sealed class State + { + public List Results { get; } = new(); + public HashSet PendingTaskIds { get; } = new(); + + public bool IsComplete => this.PendingTaskIds.Count == 0; + + public void AddPending(string taskId) => this.PendingTaskIds.Add(taskId); + public bool CompletePending(string taskId) => this.PendingTaskIds.Remove(taskId); + } protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) { @@ -81,28 +96,40 @@ internal static class Step8EntryPoint .AddHandler(this.CollectResultAsync); } - private async ValueTask StartProcessingAsync(List texts, IWorkflowContext context) + private async ValueTask StartProcessingAsync(List texts, IWorkflowContext context, CancellationToken cancellationToken) { - foreach (TextProcessingRequest request in texts.Select((string value, int index) => new TextProcessingRequest(Text: value, TaskId: $"Task{index}"))) + await this.InvokeWithStateAsync(QueueProcessingTasksAsync, context, cancellationToken: cancellationToken); + + async ValueTask QueueProcessingTasksAsync(State state, IWorkflowContext context, CancellationToken cancellationToken) { - this.PendingTaskIds.Add(request.TaskId); - await context.SendMessageAsync(request).ConfigureAwait(false); + foreach (TextProcessingRequest request in texts.Select((string value, int index) => new TextProcessingRequest(Text: value, TaskId: $"Task{index}"))) + { + state.PendingTaskIds.Add(request.TaskId); + await context.SendMessageAsync(request, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + return state; } } - private ValueTask CollectResultAsync(TextProcessingResult result, IWorkflowContext context) + private async ValueTask CollectResultAsync(TextProcessingResult result, IWorkflowContext context, CancellationToken cancellationToken = default) { - if (this.PendingTaskIds.Remove(result.TaskId)) - { - this.Results.Add(result); - } + await this.InvokeWithStateAsync(CollectResultAndCheckCompletionAsync, context, cancellationToken: cancellationToken); - if (this.PendingTaskIds.Count == 0) + async ValueTask CollectResultAndCheckCompletionAsync(State state, IWorkflowContext context, CancellationToken cancellationToken) { - return context.AddEventAsync(new AllTasksCompletedEvent(this.Results)); - } + if (state.PendingTaskIds.Remove(result.TaskId)) + { + state.Results.Add(result); + } - return default; + if (state.PendingTaskIds.Count == 0) + { + await context.YieldOutputAsync(state.Results, cancellationToken).ConfigureAwait(false); + } + + return state; + } } } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/09_Subworkflow_ExternalRequest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/09_Subworkflow_ExternalRequest.cs index 083221ab2a..7f9b6189bc 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/09_Subworkflow_ExternalRequest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/09_Subworkflow_ExternalRequest.cs @@ -7,8 +7,6 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using FluentAssertions; -using Microsoft.Agents.AI.Workflows.InProc; -using Microsoft.Agents.AI.Workflows.UnitTests; namespace Microsoft.Agents.AI.Workflows.Sample; @@ -16,13 +14,26 @@ internal sealed record class UserRequest(string RequestType, string Type, int Am { internal static int RequestCount; - public static string CreateId() => Interlocked.Increment(ref RequestCount).ToString(); + public static string CreateId() + { + string result = Interlocked.Increment(ref RequestCount).ToString(); + Console.Error.WriteLine($"Got Id: {result}"); + return result; + } - public static UserRequest CreateResourceRequest(string resourceType = "cpu", int amount = 1, string priority = "normal") => - new("resource", resourceType, amount, Priority: priority, Id: CreateId()); + public static UserRequest CreateResourceRequest(string resourceType = "cpu", int amount = 1, string priority = "normal") + { + UserRequest request = new("resource", resourceType, amount, Priority: priority, Id: CreateId()); + Console.Error.WriteLine($"\t{request}"); + return request; + } - public static UserRequest CreatePolicyCheckRequest(string resourceType = "cpu", int amount = 1, string policyType = "quota") => - new("policy", resourceType, amount, PolicyType: policyType, Id: CreateId()); + public static UserRequest CreatePolicyCheckRequest(string resourceType = "cpu", int amount = 1, string policyType = "quota") + { + UserRequest request = new("policy", resourceType, amount, PolicyType: policyType, Id: CreateId()); + Console.Error.WriteLine($"\t{request}"); + return request; + } public ResourceResponse CreateResourceResponse(int allocated, string source) => new(this.Id, this.Type, allocated, source); @@ -155,7 +166,7 @@ internal static class Step9EntryPoint public static UserRequest[] RequestsToProcess => [ ResourceHitRequest1, PolicyHitRequest1, - ResourceHitRequest1, + ResourceHitRequest2, PolicyMissRequest1, // miss ResourceMissRequest, // miss PolicyHitRequest2, @@ -172,13 +183,12 @@ internal static class Step9EntryPoint .Select(request => Part2FinishedResponses[request.Id]) .OrderBy(request => request.Id)]; - public static async ValueTask> RunAsync(TextWriter writer, ExecutionMode executionMode) + public static async ValueTask> RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment) { RunStatus runStatus; List results = []; - InProcessExecutionEnvironment env = executionMode.GetEnvironment(); - Run workflowRun = await env.RunAsync(WorkflowInstance, RequestsToProcess.ToList()); + Run workflowRun = await environment.RunAsync(WorkflowInstance, RequestsToProcess.ToList()); RunStatus part1Status = ExpectedResponsesPart2.Length > 0 ? RunStatus.PendingRequests : RunStatus.Idle; runStatus = await workflowRun.GetStatusAsync(); @@ -205,6 +215,11 @@ internal static class Step9EntryPoint policyRequests.Add(requestInfoEvent.Request); } } + else if (evt is WorkflowErrorEvent error) + { + Assert.Fail(((Exception)error.Data!).ToString()); + Console.Error.WriteLine(error.Data); + } } finishedRequests.Sort((left, right) => StringComparer.Ordinal.Compare(left.Id, right.Id)); @@ -260,7 +275,7 @@ internal static class Step9EntryPoint } } -internal sealed class ResourceRequestor() : Executor(nameof(ResourceRequestor)) +internal sealed class ResourceRequestor() : Executor(nameof(ResourceRequestor), declareCrossRunShareable: true) { protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) { @@ -304,17 +319,18 @@ internal sealed class ResourceRequestor() : Executor(nameof(ResourceRequestor)) await context.YieldOutputAsync(new RequestFinished(response.Id, RequestType: "policy", PolicyResponse: response)); } } - -internal sealed class ResourceCache() : Executor(nameof(ResourceCache)) +internal sealed class ResourceCache() + : StatefulExecutor>(nameof(ResourceCache), + InitializeResourceCache, + declareCrossRunShareable: true) { - private readonly Dictionary _availableResources = new() - { - ["cpu"] = 10, - ["memory"] = 50, - ["disk"] = 100, - }; - - internal List Responses { get; } = []; + private static Dictionary InitializeResourceCache() + => new() + { + ["cpu"] = 10, + ["memory"] = 50, + ["disk"] = 100, + }; protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) { @@ -324,45 +340,60 @@ internal sealed class ResourceCache() : Executor(nameof(ResourceCache)) .AddHandler(this.CollectResultAsync); } - private async ValueTask UnwrapAndHandleRequestAsync(ExternalRequest request, IWorkflowContext context) + private async ValueTask UnwrapAndHandleRequestAsync(ExternalRequest request, IWorkflowContext context, CancellationToken cancellationToken = default) { if (request.DataIs(out ResourceRequest? resourceRequest)) { - ResourceResponse? response = await this.TryHandleResourceRequestAsync(resourceRequest, context) + ResourceResponse? response = await this.TryHandleResourceRequestAsync(resourceRequest, context, cancellationToken) .ConfigureAwait(false); if (response != null) { - await context.SendMessageAsync(request.CreateResponse(response)).ConfigureAwait(false); + await context.SendMessageAsync(request.CreateResponse(response), cancellationToken: cancellationToken).ConfigureAwait(false); } else { // Cache does not have enough resources, forward the request to the external system - await context.SendMessageAsync(request).ConfigureAwait(false); + await context.SendMessageAsync(request, cancellationToken: cancellationToken).ConfigureAwait(false); } } } - private async ValueTask TryHandleResourceRequestAsync(ResourceRequest request, IWorkflowContext context) + private async ValueTask TryHandleResourceRequestAsync(ResourceRequest request, IWorkflowContext context, CancellationToken cancellationToken = default) { - if (this._availableResources.TryGetValue(request.ResourceType, out int available) && available >= request.Amount) + Console.Error.WriteLine($"Handling Resource Request {request.Id}"); + + Dictionary availableResources = await this.ReadStateAsync(context, cancellationToken: cancellationToken) + .ConfigureAwait(false); + + Console.Error.WriteLine($"Available Resources: {availableResources}"); + + try { - // Cache has enough resources, allocate from cache - this._availableResources[request.ResourceType] -= request.Amount; - ResourceResponse resourceResponse = new(request.Id, request.ResourceType, request.Amount, Source: "cache"); - this.Responses.Add(resourceResponse); - return resourceResponse; + if (availableResources.TryGetValue(request.ResourceType, out int available) && available >= request.Amount) + { + // Cache has enough resources, allocate from cache + availableResources[request.ResourceType] -= request.Amount; + + Console.Error.WriteLine($"Handled Resource Request {request.Id}"); + return new(request.Id, request.ResourceType, request.Amount, Source: "cache"); + } + } + finally + { + await this.QueueStateUpdateAsync(availableResources, context, cancellationToken) + .ConfigureAwait(false); } + Console.Error.WriteLine($"Could not handle Resource Request {request.Id}"); return null; } private ValueTask CollectResultAsync(ExternalResponse response, IWorkflowContext context) { - if (response.DataIs(out ResourceResponse? resourceResponse)) + if (response.DataIs()) { // Normally we'd update the cache according to whatever logic we want here. - this.Responses.Add(resourceResponse); return context.SendMessageAsync(response); } @@ -370,16 +401,18 @@ internal sealed class ResourceCache() : Executor(nameof(ResourceCache)) } } -internal sealed class QuotaPolicyEngine() : Executor(nameof(QuotaPolicyEngine)) +internal sealed class QuotaPolicyEngine() + : StatefulExecutor>(nameof(QuotaPolicyEngine), + InitializePolicyQuotas, + declareCrossRunShareable: true) { - private readonly Dictionary _quotas = new() - { - ["cpu"] = 5, - ["memory"] = 20, - ["disk"] = 1000, - }; - - internal List Responses { get; } = []; + private static Dictionary InitializePolicyQuotas() + => new() + { + ["cpu"] = 5, + ["memory"] = 20, + ["disk"] = 1000, + }; protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) { @@ -406,25 +439,39 @@ internal sealed class QuotaPolicyEngine() : Executor(nameof(QuotaPolicyEngine)) } } - private async ValueTask TryHandlePolicyCheckRequestAsync(PolicyCheckRequest request, IWorkflowContext context) + private async ValueTask TryHandlePolicyCheckRequestAsync(PolicyCheckRequest request, IWorkflowContext context, CancellationToken cancellationToken = default) { - if (request.PolicyType == "quota" && - this._quotas.TryGetValue(request.ResourceType, out int quota) && - request.Amount <= quota) + Console.Error.WriteLine($"Handling Policy Request {request.Id}"); + + Dictionary quotas = await this.ReadStateAsync(context, cancellationToken: cancellationToken) + .ConfigureAwait(false); + + Console.Error.WriteLine($"Policy Quotas: {quotas}"); + + try { - PolicyResponse policyResponse = new(request.Id, Approved: true, Reason: $"Within quota ({quota})"); - this.Responses.Add(policyResponse); + if (request.PolicyType == "quota" && + quotas.TryGetValue(request.ResourceType, out int quota) && + request.Amount <= quota) + { + Console.Error.WriteLine($"Handled Policy Request {request.Id}"); - return policyResponse; + return new(request.Id, Approved: true, Reason: $"Within quota ({quota})"); + } + + Console.Error.WriteLine($"Could not handle Policy Request {request.Id}"); + + return null; + } + finally + { + await this.QueueStateUpdateAsync(quotas, context, cancellationToken).ConfigureAwait(false); } - - return null; } private ValueTask CollectAndForwardAsync(ExternalResponse response, IWorkflowContext context) { - if (response.DataIs(out PolicyResponse? policyResponse)) + if (response.DataIs()) { - this.Responses.Add(policyResponse); return context.SendMessageAsync(response); } @@ -432,11 +479,9 @@ internal sealed class QuotaPolicyEngine() : Executor(nameof(QuotaPolicyEngine)) } } -internal sealed class Coordinator() : Executor(nameof(Coordinator)) +internal sealed class Coordinator() : Executor(nameof(Coordinator), declareCrossRunShareable: true) { - private int _inflightRequests; - - internal List Results { get; } = []; + private const string StateKey = nameof(StateKey); protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) { @@ -447,24 +492,34 @@ internal sealed class Coordinator() : Executor(nameof(Coordinator)) // For some reason, using a lambda here causes the analyzer to generate a spurious // VSTHRD110: "Observe the awaitable result of this method call by awaiting it, assigning // to a variable, or passing it to another method" - ValueTask InvokeStartAsync(UserRequest request, IWorkflowContext context) - => this.StartAsync([request], context); + ValueTask InvokeStartAsync(UserRequest request, IWorkflowContext context, CancellationToken cancellationToken) + => this.StartAsync([request], context, cancellationToken); } - private ValueTask HandleFinishedRequestAsync(RequestFinished finished, IWorkflowContext context) + private ValueTask HandleFinishedRequestAsync(RequestFinished finished, IWorkflowContext context, CancellationToken cancellationToken) { - this.Results.Add(finished); - Interlocked.Decrement(ref this._inflightRequests); + return context.InvokeWithStateAsync(CountFinishedRequestAndYieldResultAsync, StateKey, cancellationToken: cancellationToken); - return context.YieldOutputAsync(finished); - } - - private async ValueTask StartAsync(List request, IWorkflowContext context) - { - Interlocked.Add(ref this._inflightRequests, request.Count); - foreach (UserRequest req in request) + async ValueTask CountFinishedRequestAndYieldResultAsync(int state, IWorkflowContext context, CancellationToken cancellationToken) { - await context.SendMessageAsync(req).ConfigureAwait(false); + await context.YieldOutputAsync(finished, cancellationToken).ConfigureAwait(false); + + return state - 1; + } + } + + private ValueTask StartAsync(List requests, IWorkflowContext context, CancellationToken cancellationToken) + { + return context.InvokeWithStateAsync(CountFinishedRequestAndYieldResultAsync, StateKey, cancellationToken: cancellationToken); + + async ValueTask CountFinishedRequestAndYieldResultAsync(int state, IWorkflowContext context, CancellationToken cancellationToken) + { + foreach (UserRequest req in requests) + { + await context.SendMessageAsync(req, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + return state + requests.Count; } } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs index 378475a856..7730063ec2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs @@ -11,16 +11,24 @@ using Microsoft.Agents.AI.Workflows.Sample; namespace Microsoft.Agents.AI.Workflows.UnitTests; +internal enum ExecutionEnvironment +{ + InProcess_Lockstep, + InProcess_OffThread, + InProcess_Concurrent +} + public class SampleSmokeTest { [Theory] - [InlineData(ExecutionMode.Lockstep)] - [InlineData(ExecutionMode.OffThread)] - internal async Task Test_RunSample_Step1Async(ExecutionMode executionMode) + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Concurrent)] + internal async Task Test_RunSample_Step1Async(ExecutionEnvironment environment) { using StringWriter writer = new(); - await Step1EntryPoint.RunAsync(writer, executionMode); + await Step1EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment()); string result = writer.ToString(); string[] lines = result.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries); @@ -34,13 +42,14 @@ public class SampleSmokeTest } [Theory] - [InlineData(ExecutionMode.Lockstep)] - [InlineData(ExecutionMode.OffThread)] - internal async Task Test_RunSample_Step1aAsync(ExecutionMode executionMode) + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Concurrent)] + internal async Task Test_RunSample_Step1aAsync(ExecutionEnvironment environment) { using StringWriter writer = new(); - await Step1aEntryPoint.RunAsync(writer, executionMode); + await Step1aEntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment()); string result = writer.ToString(); string[] lines = result.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries); @@ -54,37 +63,40 @@ public class SampleSmokeTest } [Theory] - [InlineData(ExecutionMode.Lockstep)] - [InlineData(ExecutionMode.OffThread)] - internal async Task Test_RunSample_Step2Async(ExecutionMode executionMode) + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Concurrent)] + internal async Task Test_RunSample_Step2Async(ExecutionEnvironment environment) { using StringWriter writer = new(); - string spamResult = await Step2EntryPoint.RunAsync(writer, executionMode); + string spamResult = await Step2EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment()); Assert.Equal(RemoveSpamExecutor.ActionResult, spamResult); - string nonSpamResult = await Step2EntryPoint.RunAsync(writer, executionMode, "This is a valid message."); + string nonSpamResult = await Step2EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment(), "This is a valid message."); Assert.Equal(RespondToMessageExecutor.ActionResult, nonSpamResult); } [Theory] - [InlineData(ExecutionMode.Lockstep)] - [InlineData(ExecutionMode.OffThread)] - internal async Task Test_RunSample_Step3Async(ExecutionMode executionMode) + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Concurrent)] + internal async Task Test_RunSample_Step3Async(ExecutionEnvironment environment) { using StringWriter writer = new(); - string guessResult = await Step3EntryPoint.RunAsync(writer, executionMode); + string guessResult = await Step3EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment()); Assert.Equal("Guessed the number: 42", guessResult); } [Theory] - [InlineData(ExecutionMode.Lockstep)] - [InlineData(ExecutionMode.OffThread)] - internal async Task Test_RunSample_Step4Async(ExecutionMode executionMode) + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Concurrent)] + internal async Task Test_RunSample_Step4Async(ExecutionEnvironment environment) { using StringWriter writer = new(); @@ -93,14 +105,15 @@ public class SampleSmokeTest ("Your guess was too high. Try again.", 23), ("Your guess was too low. Try again.", 42)); - string guessResult = await Step4EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, executionMode); + string guessResult = await Step4EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, environment.ToWorkflowExecutionEnvironment()); Assert.Equal("You guessed correctly! You Win!", guessResult); } [Theory] - [InlineData(ExecutionMode.Lockstep)] - [InlineData(ExecutionMode.OffThread)] - internal async Task Test_RunSample_Step5Async(ExecutionMode executionMode) + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Concurrent)] + internal async Task Test_RunSample_Step5Async(ExecutionEnvironment environment) { using StringWriter writer = new(); @@ -114,14 +127,15 @@ public class SampleSmokeTest ("Your guess was too low. Try again.", 42) ); - string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, executionMode); + string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, environment.ToWorkflowExecutionEnvironment()); Assert.Equal("You guessed correctly! You Win!", guessResult); } [Theory] - [InlineData(ExecutionMode.Lockstep)] - [InlineData(ExecutionMode.OffThread)] - internal async Task Test_RunSample_Step5aAsync(ExecutionMode executionMode) + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Concurrent)] + internal async Task Test_RunSample_Step5aAsync(ExecutionEnvironment environment) { using StringWriter writer = new(); @@ -135,14 +149,15 @@ public class SampleSmokeTest ("Your guess was too low. Try again.", 42) ); - string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, executionMode, rehydrateToRestore: true); + string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, environment.ToWorkflowExecutionEnvironment(), rehydrateToRestore: true); Assert.Equal("You guessed correctly! You Win!", guessResult); } [Theory] - [InlineData(ExecutionMode.Lockstep)] - [InlineData(ExecutionMode.OffThread)] - internal async Task Test_RunSample_Step5bAsync(ExecutionMode executionMode) + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Concurrent)] + internal async Task Test_RunSample_Step5bAsync(ExecutionEnvironment environment) { using StringWriter writer = new(); @@ -160,18 +175,19 @@ public class SampleSmokeTest options.MakeReadOnly(); CheckpointManager memoryJsonManager = CheckpointManager.CreateJson(new InMemoryJsonStore(), options); - string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, executionMode, rehydrateToRestore: true, checkpointManager: memoryJsonManager); + string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, environment.ToWorkflowExecutionEnvironment(), rehydrateToRestore: true, checkpointManager: memoryJsonManager); Assert.Equal("You guessed correctly! You Win!", guessResult); } [Theory] - [InlineData(ExecutionMode.Lockstep)] - [InlineData(ExecutionMode.OffThread)] - internal async Task Test_RunSample_Step6Async(ExecutionMode executionMode) + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Concurrent)] + internal async Task Test_RunSample_Step6Async(ExecutionEnvironment environment) { using StringWriter writer = new(); - await Step6EntryPoint.RunAsync(writer, executionMode); + await Step6EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment()); string result = writer.ToString(); string[] lines = result.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries); @@ -199,9 +215,10 @@ public class SampleSmokeTest } [Theory] - [InlineData(ExecutionMode.Lockstep)] - [InlineData(ExecutionMode.OffThread)] - internal async Task Test_RunSample_Step8Async(ExecutionMode executionMode) + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Concurrent)] + internal async Task Test_RunSample_Step8Async(ExecutionEnvironment environment) { List textsToProcess = [ "Hello world! This is a simple test.", @@ -214,7 +231,7 @@ public class SampleSmokeTest using StringWriter writer = new(); - List results = await Step8EntryPoint.RunAsync(writer, executionMode, textsToProcess); + List results = await Step8EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment(), textsToProcess); Assert.Equal(textsToProcess.Count, results.Count); Assert.Collection(results, @@ -237,12 +254,13 @@ public class SampleSmokeTest } [Theory] - [InlineData(ExecutionMode.Lockstep)] - [InlineData(ExecutionMode.OffThread)] - internal async Task Test_RunSample_Step9Async(ExecutionMode executionMode) + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Concurrent)] + internal async Task Test_RunSample_Step9Async(ExecutionEnvironment environment) { using StringWriter writer = new(); - _ = await Step9EntryPoint.RunAsync(writer, executionMode); + _ = await Step9EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment()); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SpecializedExecutorSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SpecializedExecutorSmokeTests.cs index 9aea98f068..87afd18d69 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SpecializedExecutorSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SpecializedExecutorSmokeTests.cs @@ -8,6 +8,7 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; using FluentAssertions; +using Microsoft.Agents.AI.Workflows.Execution; using Microsoft.Agents.AI.Workflows.Specialized; using Microsoft.Extensions.AI; @@ -111,8 +112,10 @@ public class SpecializedExecutorSmokeTests public sealed class TestAgentThread() : InMemoryAgentThread(); - internal sealed class TestWorkflowContext : IWorkflowContext + internal sealed class TestWorkflowContext(string executorId, bool concurrentRunsEnabled = false) : IWorkflowContext { + private readonly StateManager _stateManager = new(); + public List> Updates { get; } = []; public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default) => @@ -124,17 +127,19 @@ public class SpecializedExecutorSmokeTests public ValueTask RequestHaltAsync() => default; - public ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default) => - default; + public ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default) + => this._stateManager.ClearStateAsync(new ScopeId(executorId, scopeName)); - public ValueTask QueueStateUpdateAsync(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default) => - default; + public ValueTask QueueStateUpdateAsync(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default) + => value is null + ? this._stateManager.ClearStateAsync(new ScopeId(executorId, scopeName), key) + : this._stateManager.WriteStateAsync(new ScopeId(executorId, scopeName), key, value); - public ValueTask ReadStateAsync(string key, string? scopeName = null, CancellationToken cancellationToken = default) => - throw new NotImplementedException(); + public ValueTask ReadStateAsync(string key, string? scopeName = null, CancellationToken cancellationToken = default) + => this._stateManager.ReadStateAsync(new ScopeId(executorId, scopeName), key); - public ValueTask> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default) => - throw new NotImplementedException(); + public ValueTask> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default) + => this._stateManager.ReadKeysAsync(new ScopeId(executorId, scopeName)); public ValueTask SendMessageAsync(object message, string? targetId = null, CancellationToken cancellationToken = default) { @@ -150,7 +155,15 @@ public class SpecializedExecutorSmokeTests return default; } + public async ValueTask ReadOrInitStateAsync(string key, Func initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default) + { + return (await this.ReadStateAsync(key, scopeName, cancellationToken).ConfigureAwait(false)) + ?? initialStateFactory(); + } + public IReadOnlyDictionary? TraceContext => null; + + public bool ConcurrentRunsEnabled => concurrentRunsEnabled; } [Fact] @@ -177,7 +190,7 @@ public class SpecializedExecutorSmokeTests TestAIAgent agent = new(expected); AIAgentHostExecutor host = new(agent); - TestWorkflowContext collectingContext = new(); + TestWorkflowContext collectingContext = new(host.Id); await host.TakeTurnAsync(new TurnToken(emitEvents: false), collectingContext); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRunContext.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRunContext.cs index 17935892a6..57375b8341 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRunContext.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRunContext.cs @@ -39,7 +39,14 @@ public class TestRunContext : IRunnerContext public ValueTask SendMessageAsync(object message, string? targetId = null, CancellationToken cancellationToken = default) => runnerContext.SendMessageAsync(executorId, message, targetId, cancellationToken); + public ValueTask ReadOrInitStateAsync(string key, Func initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default) + { + return new(initialStateFactory()); + } + public IReadOnlyDictionary? TraceContext => traceContext; + + public bool ConcurrentRunsEnabled => runnerContext.ConcurrentRunsEnabled; } public List Events { get; } = []; @@ -79,6 +86,7 @@ public class TestRunContext : IRunnerContext public string StartingExecutorId { get; set; } = string.Empty; public bool WithCheckpointing => throw new NotSupportedException(); + public bool ConcurrentRunsEnabled => throw new NotSupportedException(); ValueTask IRunnerContext.EnsureExecutorAsync(string executorId, IStepTracer? tracer, CancellationToken cancellationToken) => new(this.Executors[executorId]); @@ -99,5 +107,6 @@ public class TestRunContext : IRunnerContext public ValueTask SendMessageAsync(string senderId, [System.Diagnostics.CodeAnalysis.DisallowNull] TMessage message, CancellationToken cancellationToken = default) => this.SendMessageAsync(senderId, message, cancellationToken); - ValueTask ISuperStepJoinContext.AttachSuperstepAsync(ISuperStepRunner superStepRunner, CancellationToken cancellationToken) => default; + ValueTask ISuperStepJoinContext.AttachSuperstepAsync(ISuperStepRunner superStepRunner, CancellationToken cancellationToken) => new(string.Empty); + ValueTask ISuperStepJoinContext.DetachSuperstepAsync(string joinId) => new(false); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRunState.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRunState.cs new file mode 100644 index 0000000000..402544fdb6 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRunState.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Concurrent; +using System.Threading; +using Microsoft.Agents.AI.Workflows.Execution; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +internal sealed class TestRunState +{ + public ConcurrentDictionary> SentMessages = new(); + public StateManager StateManager { get; } = new(); + public ConcurrentQueue EmittedEvents { get; } = new(); + public ConcurrentDictionary> YieldedOutputs { get; } = new(); + + private int _haltRequests; + public int HaltRequests + { + get => Volatile.Read(ref this._haltRequests); + } + + public void IncrementHaltRequests() + { + Interlocked.Increment(ref this._haltRequests); + } + + public TestWorkflowContext ContextFor(string executorId) => new(executorId, this); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestWorkflowContext.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestWorkflowContext.cs new file mode 100644 index 0000000000..61fb4e1970 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestWorkflowContext.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Execution; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +internal sealed class TestWorkflowContext : IWorkflowContext +{ + private readonly string _executorId; + private readonly TestRunState _state; + + public TestWorkflowContext(string executorId, TestRunState? state = null, bool concurrentRunsEnabled = false) + { + this._executorId = executorId; + this._state = state ?? new TestRunState(); + + this.ConcurrentRunsEnabled = concurrentRunsEnabled; + } + + public bool ConcurrentRunsEnabled { get; } + + public ConcurrentQueue SentMessages => this._state.SentMessages.GetOrAdd(this._executorId, _ => new()); + + public StateManager StateManager => this._state.StateManager; + + public ConcurrentQueue EmittedEvents => this._state.EmittedEvents; + + public ConcurrentQueue YieldedOutputs => this._state.YieldedOutputs.GetOrAdd(this._executorId, _ => new()); + + public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default) + { + this.EmittedEvents.Enqueue(workflowEvent); + return default; + } + + public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default) + { + this.YieldedOutputs.Enqueue(output); + return this.AddEventAsync(new WorkflowOutputEvent(output, this._executorId), cancellationToken); + } + + public ValueTask RequestHaltAsync() + { + this._state.IncrementHaltRequests(); + return default; + } + + public ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default) + => this.StateManager.ClearStateAsync(new ScopeId(this._executorId, scopeName)); + + public ValueTask QueueStateUpdateAsync(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default) + => this.StateManager.WriteStateAsync(new ScopeId(this._executorId, scopeName), key, value); + + public ValueTask ReadStateAsync(string key, string? scopeName = null, CancellationToken cancellationToken = default) + => this.StateManager.ReadStateAsync(new ScopeId(this._executorId, scopeName), key); + + public ValueTask ReadOrInitStateAsync(string key, Func initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default) + => this.StateManager.ReadOrInitStateAsync(new ScopeId(this._executorId, scopeName), key, initialStateFactory); + + public ValueTask> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default) + => this.StateManager.ReadKeysAsync(new ScopeId(this._executorId, scopeName)); + + public ValueTask SendMessageAsync(object message, string? targetId = null, CancellationToken cancellationToken = default) + { + this.SentMessages.Enqueue(message); + return default; + } + + public IReadOnlyDictionary? TraceContext => null; +}