diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index 1cd1b2bc94..ba57492eda 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -182,7 +182,13 @@ protected override void Visit(Foreach item) this.Trace(item); // Entry point for loop - ForeachExecutor action = new(item, this._workflowState); + ForeachExecutor action = new(item, this._workflowState, this._workflowOptions); + if (action.IsParallel) + { + this.ContinueWith(action); + return; + } + string loopId = ForeachExecutor.Steps.Next(action.Id); this.ContinueWith(action, condition: null, CompletionHandler); // Transition to select the next item diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowElementWalker.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowElementWalker.cs index 4be8bb8892..7b9d36b969 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowElementWalker.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowElementWalker.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; using Microsoft.Agents.ObjectModel; namespace Microsoft.Agents.AI.Workflows.Declarative.Interpreter; @@ -18,6 +19,11 @@ public override bool DefaultVisit(BotElement definition) if (definition is DialogAction action) { action.Accept(this._visitor); + + if (action is Foreach foreachAction && ForeachExecutionOptions.Parse(foreachAction).IsParallel) + { + return false; + } } return true; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ForeachExecutionOptions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ForeachExecutionOptions.cs new file mode 100644 index 0000000000..401139adf0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ForeachExecutionOptions.cs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Globalization; +using Microsoft.Agents.AI.Workflows.Declarative.Extensions; +using Microsoft.Agents.ObjectModel; + +namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; + +internal enum ForeachExecutionMode +{ + Sequential, + Parallel, +} + +/// +/// Strongly typed adapter for Foreach execution fields preserved by the external ObjectModel package. +/// +/// +/// Once exposes generated properties for these fields, only this adapter needs to change. +/// +internal sealed record ForeachExecutionOptions( + ForeachExecutionMode Mode, + int MaxParallelism, + TimeSpan? IterationTimeout) +{ + internal const string ModePropertyName = "mode"; + internal const string MaxParallelismPropertyName = "maxParallelism"; + internal const string TimeoutPropertyName = "timeoutInMilliseconds"; + + private const int DefaultMaxParallelism = 4; + + public bool IsParallel => this.Mode == ForeachExecutionMode.Parallel; + + public static ForeachExecutionOptions Parse(Foreach model) + { + DataValue? modeValue = GetExtensionValue(model, ModePropertyName); + DataValue? maxParallelismValue = GetExtensionValue(model, MaxParallelismPropertyName); + DataValue? timeoutValue = GetExtensionValue(model, TimeoutPropertyName); + + ForeachExecutionMode mode = ParseMode(model, modeValue); + int maxParallelism = ParseInteger(model, MaxParallelismPropertyName, maxParallelismValue) ?? DefaultMaxParallelism; + int? timeoutMilliseconds = ParseInteger(model, TimeoutPropertyName, timeoutValue); + + if (mode == ForeachExecutionMode.Sequential && (maxParallelismValue is not null || timeoutValue is not null)) + { + throw InvalidConfiguration(model, $"'{MaxParallelismPropertyName}' and '{TimeoutPropertyName}' require '{ModePropertyName}: Parallel'."); + } + + if (maxParallelism <= 0) + { + throw InvalidConfiguration(model, $"'{MaxParallelismPropertyName}' must be greater than zero."); + } + + if (timeoutMilliseconds <= 0) + { + throw InvalidConfiguration(model, $"'{TimeoutPropertyName}' must be greater than zero when specified."); + } + + return new(mode, maxParallelism, timeoutMilliseconds.HasValue ? TimeSpan.FromMilliseconds(timeoutMilliseconds.Value) : null); + } + + private static ForeachExecutionMode ParseMode(Foreach model, DataValue? value) + { + if (value is null) + { + return ForeachExecutionMode.Sequential; + } + + if (value is not StringDataValue stringValue) + { + throw InvalidConfiguration(model, $"'{ModePropertyName}' must be 'Sequential' or 'Parallel'."); + } + + if (string.Equals(stringValue.Value, nameof(ForeachExecutionMode.Sequential), StringComparison.OrdinalIgnoreCase)) + { + return ForeachExecutionMode.Sequential; + } + + if (string.Equals(stringValue.Value, nameof(ForeachExecutionMode.Parallel), StringComparison.OrdinalIgnoreCase)) + { + return ForeachExecutionMode.Parallel; + } + + throw InvalidConfiguration(model, $"'{ModePropertyName}' must be 'Sequential' or 'Parallel'."); + } + + private static int? ParseInteger(Foreach model, string propertyName, DataValue? value) + { + if (value is null) + { + return null; + } + + object? rawValue = value.ToFormula().ToObject(); + if (rawValue is not (byte or sbyte or short or ushort or int or uint or long or ulong or float or double or decimal)) + { + throw InvalidConfiguration(model, $"'{propertyName}' must be an integer."); + } + + decimal decimalValue; + try + { + decimalValue = Convert.ToDecimal(rawValue, CultureInfo.InvariantCulture); + } + catch (Exception exception) when (exception is FormatException or InvalidCastException or OverflowException) + { + throw InvalidConfiguration(model, $"'{propertyName}' must be an integer.", exception); + } + + if (decimalValue != decimal.Truncate(decimalValue) || decimalValue < int.MinValue || decimalValue > int.MaxValue) + { + throw InvalidConfiguration(model, $"'{propertyName}' must be an integer."); + } + + return decimal.ToInt32(decimalValue); + } + + private static DataValue? GetExtensionValue(Foreach model, string propertyName) => + model.ExtensionData?.Properties.TryGetValue(propertyName, out DataValue? value) is true ? value : null; + + private static DeclarativeModelException InvalidConfiguration(Foreach model, string message, Exception? innerException = null) => + new($"Invalid Foreach configuration for '{model.Id.Value}': {message}", innerException); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ForeachExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ForeachExecutor.cs index f154ad7f97..db85cbed6e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ForeachExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ForeachExecutor.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -29,36 +30,177 @@ public static class Steps private int _index; private FormulaValue[] _values; + private readonly ForeachExecutionOptions _executionOptions; + private readonly DeclarativeWorkflowOptions? _workflowOptions; + private readonly WorkflowFormulaState _workflowState; - public ForeachExecutor(Foreach model, WorkflowFormulaState state) + public ForeachExecutor(Foreach model, WorkflowFormulaState state, DeclarativeWorkflowOptions? workflowOptions = null) : base(model, state) { this._values = []; + this._executionOptions = ForeachExecutionOptions.Parse(model); + this._workflowOptions = workflowOptions; + this._workflowState = state; + + if (this._executionOptions.IsParallel) + { + if (workflowOptions is null) + { + throw new DeclarativeModelException($"Parallel Foreach '{model.Id.Value}' requires workflow execution options."); + } + + ParallelForeachIterationRunner.ValidateBody(model); + } } public bool HasValue { get; private set; } - protected override bool IsDiscreteAction => false; + public bool IsParallel => this._executionOptions.IsParallel; + + protected override bool IsDiscreteAction => this.IsParallel; protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + return this.IsParallel + ? await this.ExecuteParallelAsync(context, cancellationToken).ConfigureAwait(false) + : await this.ExecuteSequentialAsync(context, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask ExecuteSequentialAsync(IWorkflowContext context, CancellationToken cancellationToken) { Throw.IfNull(this.Model.Items, $"{nameof(this.Model)}.{nameof(this.Model.Items)}"); this._index = 0; + this._values = this.GetValues(); - EvaluationResult expressionResult = this.Evaluator.GetValue(this.Model.Items); - if (expressionResult.Value is TableDataValue tableValue) + await this.ResetStateAsync(context, cancellationToken).ConfigureAwait(false); + + return default; + } + + private async ValueTask ExecuteParallelAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + Throw.IfNull(this.Model.Items, $"{nameof(this.Model)}.{nameof(this.Model.Items)}"); + DeclarativeWorkflowOptions workflowOptions = Throw.IfNull(this._workflowOptions); + FormulaValue[] values = this.GetValues(); + + await this.ResetStateAsync(context, cancellationToken).ConfigureAwait(false); + + try { - this._values = [.. tableValue.Values.Select(ToLoopValue)]; + if (values.Length == 0) + { + return default; + } + + WorkflowStateSnapshot stateSnapshot = this._workflowState.CaptureSnapshot(); + ParallelForeachIterationResult?[] iterationResults = new ParallelForeachIterationResult?[values.Length]; + Exception?[] iterationFailures = new Exception?[values.Length]; + int nextIndex = -1; + + using CancellationTokenSource groupCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + int workerCount = Math.Min(values.Length, this._executionOptions.MaxParallelism); + Task[] workers = + [ + .. Enumerable.Range(0, workerCount).Select(_ => RunWorkerAsync()), + ]; + + await Task.WhenAll(workers).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + Exception[] failures = + [ + .. iterationFailures + .Select( + (exception, index) => + exception is null + ? null + : new DeclarativeActionException( + $"Parallel Foreach '{this.Id}' iteration {index} failed.", + exception)) + .Where(exception => exception is not null) + .Cast(), + ]; + if (failures.Length > 0) + { + throw new AggregateException($"Parallel Foreach '{this.Id}' failed.", failures); + } + + foreach (ParallelForeachIterationResult iterationResult in iterationResults.Cast()) + { + foreach (WorkflowStateChange stateChange in iterationResult.StateChanges) + { + await CommitStateChangeAsync(context, stateChange, cancellationToken).ConfigureAwait(false); + } + + foreach (WorkflowEvent workflowEvent in iterationResult.Events) + { + await context.AddEventAsync(workflowEvent, cancellationToken).ConfigureAwait(false); + } + } + + return default; + + async Task RunWorkerAsync() + { + while (!groupCancellation.IsCancellationRequested) + { + int iterationIndex = Interlocked.Increment(ref nextIndex); + if (iterationIndex >= values.Length) + { + return; + } + + try + { + iterationResults[iterationIndex] = await ParallelForeachIterationRunner.RunAsync( + this.Model, + values[iterationIndex], + iterationIndex, + stateSnapshot, + workflowOptions, + this._executionOptions.IterationTimeout, + groupCancellation.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (groupCancellation.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + return; + } + catch (Exception exception) + { + iterationFailures[iterationIndex] = exception; + groupCancellation.Cancel(); + return; + } + } + } } - else + finally { - this._values = [expressionResult.Value.ToFormula()]; + await this.ResetStateAsync(context, CancellationToken.None).ConfigureAwait(false); } + } - await this.ResetStateAsync(context, cancellationToken).ConfigureAwait(false); + private static ValueTask CommitStateChangeAsync( + IWorkflowContext context, + WorkflowStateChange stateChange, + CancellationToken cancellationToken) + { + FormulaValue value = stateChange.Value.ToFormula(); + return stateChange.ScopeName switch + { + VariableScopeNames.System => context.QueueSystemUpdateAsync(stateChange.VariableName, value, cancellationToken), + VariableScopeNames.Environment => context.QueueEnvironmentUpdateAsync(stateChange.VariableName, value, cancellationToken), + _ => context.QueueStateUpdateAsync(stateChange.VariableName, value, stateChange.ScopeName, cancellationToken), + }; + } - return default; + private FormulaValue[] GetValues() + { + EvaluationResult expressionResult = this.Evaluator.GetValue(Throw.IfNull(this.Model.Items)); + return expressionResult.Value is TableDataValue tableValue + ? [.. tableValue.Values.Select(ToLoopValue)] + : [expressionResult.Value.ToFormula()]; } public async ValueTask TakeNextAsync(IWorkflowContext context, object? _, CancellationToken cancellationToken) @@ -117,6 +259,12 @@ value is RecordDataValue record /// protected override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) { + if (this.IsParallel) + { + await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false); + return; + } + PortableValue[] portableValues = [.. this._values.Select(value => new PortableValue(value.AsPortable()))]; await context.QueueStateUpdateAsync(IndexStateKey, this._index, cancellationToken: cancellationToken).ConfigureAwait(false); @@ -137,6 +285,11 @@ protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext co { await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false); + if (this.IsParallel) + { + return; + } + PortableValue[]? savedValues = await context.ReadStateAsync(ValuesStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); if (savedValues is null) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ParallelForeachIterationRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ParallelForeachIterationRunner.cs new file mode 100644 index 0000000000..0a4714b912 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ParallelForeachIterationRunner.cs @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Declarative.Extensions; +using Microsoft.Agents.AI.Workflows.Declarative.Interpreter; +using Microsoft.Agents.AI.Workflows.Declarative.Kit; +using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; +using Microsoft.Agents.ObjectModel; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; + +internal sealed record ParallelForeachIterationResult( + int Index, + WorkflowStateChange[] StateChanges, + WorkflowEvent[] Events); + +/// +/// Runs one Foreach body through the existing workflow runtime with isolated formula state. +/// +internal static class ParallelForeachIterationRunner +{ + public static void ValidateBody(Foreach model) + { + foreach (DialogAction action in model.Descendants().OfType()) + { + if (action is Question or RequestExternalInput) + { + throw new DeclarativeModelException( + $"Parallel Foreach '{model.Id.Value}' cannot safely checkpoint while action " + + $"'{action.Id.Value}' ({action.GetType().Name}) is awaiting external input."); + } + + if (action is BreakLoop or ContinueLoop && TargetsLoop(action, model)) + { + throw new DeclarativeModelException( + $"Parallel Foreach '{model.Id.Value}' does not support {action.GetType().Name} targeting the parallel loop."); + } + } + } + + public static async Task RunAsync( + Foreach model, + FormulaValue value, + int index, + WorkflowStateSnapshot stateSnapshot, + DeclarativeWorkflowOptions workflowOptions, + TimeSpan? timeout, + CancellationToken cancellationToken) + { + using CancellationTokenSource timeoutSource = new(); + using CancellationTokenSource iterationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutSource.Token); + if (timeout.HasValue) + { + timeoutSource.CancelAfter(timeout.Value); + } + + WorkflowFormulaState branchState = WorkflowFormulaState.CreateBranch(workflowOptions.CreateRecalcEngine(), stateSnapshot); + SetLoopVariable(branchState, model.Value!.Path, new PortableValue(value.AsPortable()).ToFormula()); + if (model.Index is not null) + { + SetLoopVariable(branchState, model.Index.Path, FormulaValue.New(index)); + } + branchState.Bind(); + branchState.BeginTrackingChanges(); + + Workflow workflow = BuildIterationWorkflow(model, branchState, workflowOptions); + + StreamingRun? run = null; + bool runCanceled = false; + try + { + run = await InProcessExecution.RunStreamingAsync( + workflow, + new ActionExecutorResult(model.Id.Value), + cancellationToken: iterationSource.Token).ConfigureAwait(false); + + List eventList = []; + await foreach (WorkflowEvent workflowEvent in run.WatchStreamAsync(blockOnPendingRequest: false, iterationSource.Token).ConfigureAwait(false)) + { + eventList.Add(workflowEvent); + } + + if (iterationSource.IsCancellationRequested) + { + await run.CancelRunAsync().ConfigureAwait(false); + runCanceled = true; + } + + if (timeoutSource.IsCancellationRequested) + { + throw new TimeoutException( + $"The iteration exceeded its timeout of {timeout.GetValueOrDefault().TotalMilliseconds} ms."); + } + + cancellationToken.ThrowIfCancellationRequested(); + + RunStatus status = await run.GetStatusAsync(iterationSource.Token).ConfigureAwait(false); + WorkflowEvent[] events = [.. eventList]; + + Exception[] failures = + [ + .. events + .OfType() + .Select( + workflowError => + workflowError.Data as Exception ?? + new DeclarativeActionException( + "The iteration failed without exception data.")), + ]; + if (failures.Length > 0) + { + throw failures.Length == 1 ? failures[0] : new AggregateException(failures); + } + + if (status == RunStatus.PendingRequests || events.Any(workflowEvent => workflowEvent is RequestInfoEvent)) + { + throw new DeclarativeActionException( + "The iteration requested external input. " + + "Checkpointing an in-flight parallel iteration is not supported."); + } + + if (status != RunStatus.Idle) + { + throw new DeclarativeActionException( + $"The iteration ended with unsupported status '{status}'."); + } + + WorkflowStateChange[] stateChanges = + [ + .. branchState + .CaptureChanges() + .Where(change => !Matches(change, model.Value.Path) && (model.Index is null || !Matches(change, model.Index.Path))), + ]; + WorkflowEvent[] bufferedEvents = [.. events.Where(workflowEvent => ShouldReplay(workflowEvent, model.Id.Value))]; + + return new(index, stateChanges, bufferedEvents); + } + catch (OperationCanceledException) when (timeoutSource.IsCancellationRequested) + { + throw new TimeoutException( + $"The iteration exceeded its timeout of {timeout!.Value.TotalMilliseconds} ms."); + } + finally + { + if (run is not null) + { + if (iterationSource.IsCancellationRequested && !runCanceled) + { + await run.CancelRunAsync().ConfigureAwait(false); + } + + await run.DisposeAsync().ConfigureAwait(false); + } + } + } + + private static Workflow BuildIterationWorkflow( + Foreach model, + WorkflowFormulaState state, + DeclarativeWorkflowOptions workflowOptions) + { + DelegateActionExecutor root = new(model.Id.Value, state); + WorkflowActionVisitor visitor = new(root, state, workflowOptions); + WorkflowElementWalker walker = new(visitor); + foreach (DialogAction action in model.Actions) + { + walker.Visit(action); + } + + return visitor.Complete(); + } + + private static void SetLoopVariable(WorkflowFormulaState state, PropertyPath path, FormulaValue value) => + state.Set(path.VariableName!, value, path.NamespaceAlias); + + private static bool Matches(WorkflowStateChange change, PropertyPath path) => + string.Equals(change.VariableName, path.VariableName, StringComparison.Ordinal) && + string.Equals(change.ScopeName, WorkflowFormulaState.GetScopeName(path.NamespaceAlias), StringComparison.Ordinal); + + private static bool ShouldReplay(WorkflowEvent workflowEvent, string rootExecutorId) => + (workflowEvent is not WorkflowStartedEvent + and not SuperStepEvent + and not WorkflowErrorEvent + and not RequestInfoEvent + and not ExecutorFailedEvent) && + (workflowEvent is not ExecutorEvent executorEvent || executorEvent.ExecutorId != rootExecutorId); + + private static bool TargetsLoop(DialogAction action, Foreach loop) + { + BotElement? ancestor = action.Parent; + while (ancestor is not null && ancestor is not Foreach) + { + ancestor = ancestor.Parent; + } + + return ancestor is Foreach ancestorLoop && ancestorLoop.Id.Equals(loop.Id); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowFormulaState.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowFormulaState.cs index c739cb3bf9..a39aef3911 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowFormulaState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowFormulaState.cs @@ -29,6 +29,8 @@ internal sealed class WorkflowFormulaState private readonly Dictionary _scopes; + private HashSet<(string ScopeName, string VariableName)>? _trackedChanges; + private int _isInitialized; public RecalcEngine Engine { get; } @@ -56,8 +58,75 @@ public FormulaValue Get(string variableName, string? scopeName = null) return FormulaValue.NewBlank(); } - public void Set(string variableName, FormulaValue value, string? scopeName = null) => - this.GetScope(scopeName ?? DefaultScopeName)[variableName] = value; + public void Set(string variableName, FormulaValue value, string? scopeName = null) + { + string normalizedScopeName = GetScopeName(scopeName); + this.GetScope(normalizedScopeName)[variableName] = value; + this._trackedChanges?.Add((normalizedScopeName, variableName)); + } + + /// + /// Captures a portable deep snapshot of every workflow variable scope. + /// + public WorkflowStateSnapshot CaptureSnapshot() + { + WorkflowStateEntry[] entries = + [ + .. VariableScopeNames.AllScopes + .Select(GetScopeName) + .Distinct() + .SelectMany( + scopeName => + this.Keys(scopeName) + .OrderBy(variableName => variableName, System.StringComparer.Ordinal) + .Select( + variableName => + new WorkflowStateEntry( + scopeName, + variableName, + new PortableValue(this.Get(variableName, scopeName).AsPortable())))), + ]; + + return new(entries); + } + + /// + /// Creates an isolated state instance from a previously captured snapshot. + /// + public static WorkflowFormulaState CreateBranch(RecalcEngine engine, WorkflowStateSnapshot snapshot) + { + WorkflowFormulaState branch = new(engine); + foreach (WorkflowStateEntry entry in snapshot.Entries) + { + branch.Set(entry.VariableName, entry.Value.ToFormula(), entry.ScopeName); + } + + return branch; + } + + /// + /// Starts recording the variables written after branch initialization. + /// + public void BeginTrackingChanges() => this._trackedChanges = []; + + /// + /// Captures the current values of all variables written since change tracking began. + /// + public WorkflowStateChange[] CaptureChanges() => + this._trackedChanges is null + ? [] + : + [ + .. this._trackedChanges + .OrderBy(change => change.ScopeName, System.StringComparer.Ordinal) + .ThenBy(change => change.VariableName, System.StringComparer.Ordinal) + .Select( + change => + new WorkflowStateChange( + change.ScopeName, + change.VariableName, + new PortableValue(this.Get(change.VariableName, change.ScopeName).AsPortable()))), + ]; public bool SetInitialized() => Interlocked.CompareExchange(ref this._isInitialized, 1, 0) == 0; @@ -145,3 +214,9 @@ public static string GetScopeName(string? scopeName) /// private sealed class WorkflowScope : Dictionary; } + +internal sealed record WorkflowStateEntry(string ScopeName, string VariableName, PortableValue Value); + +internal sealed record WorkflowStateChange(string ScopeName, string VariableName, PortableValue Value); + +internal sealed record WorkflowStateSnapshot(IReadOnlyList Entries); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/README.md b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/README.md index 2a50b6045d..4a8e47d9af 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/README.md +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/README.md @@ -57,4 +57,37 @@ we've provided a console application that is able to execute any declarative wor |**Foreach**|Iterates through a collection of items, executing a set of actions for each. Ideal for processing lists or batch operations. |**GotoAction**|Jumps directly to a specified action within the workflow. Enables non-linear navigation in the logic flow. +## Parallel Foreach + +`Foreach` remains sequential when execution options are omitted. Set `mode` to `Parallel` to opt in, and use +`maxParallelism` to bound the number of active iterations. The default maximum in parallel mode is 4. +`timeoutInMilliseconds`, when present, applies to each iteration. + +```yaml +- kind: Foreach + id: translate_languages + items: =Local.TargetLanguages + value: Local.TargetLanguage + index: Local.TargetLanguageIndex + mode: Parallel + maxParallelism: 4 + timeoutInMilliseconds: 30000 + actions: + - kind: InvokeAzureAgent + id: translate + agent: + name: TranslatorAgent + input: + arguments: + language: =Local.TargetLanguage +``` + +Each iteration receives an isolated copy of workflow state. After all iterations succeed, state writes and emitted +events are applied in collection order; writes to the same variable therefore use deterministic last-index-wins +semantics. A failure or timeout cancels outstanding iterations and does not commit buffered state or events. + +Parallel iterations cannot currently suspend for external input because an in-flight parallel set cannot be safely +represented by a parent workflow checkpoint. `Question` and `RequestExternalInput` actions are rejected when the +workflow is built. An action that conditionally requests external input fails the parallel Foreach at runtime instead +of creating an unsafe checkpoint. `BreakLoop` and `ContinueLoop` cannot target a parallel Foreach. diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ParallelForeachWorkflowTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ParallelForeachWorkflowTests.cs new file mode 100644 index 0000000000..54f2ed0b69 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ParallelForeachWorkflowTests.cs @@ -0,0 +1,923 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests; + +/// +/// End-to-end behavioral tests for parallel declarative Foreach execution. +/// +public sealed class ParallelForeachWorkflowTests +{ + [Fact] + public async Task ForeachRemainsSequentialByDefaultAsync() + { + // Arrange + ControlledAgentProvider provider = new(); + string yaml = CreateWorkflowYaml( + items: "=[\"a\", \"b\", \"c\"]", + executionOptions: null, + bodyActions: InvokeAgentAction); + + // Act + WorkflowObservation observation = await RunWorkflowAsync(yaml, provider); + + // Assert + AssertNoWorkflowError(observation); + Assert.Equal(3, provider.InvocationCount); + Assert.Equal(1, provider.PeakConcurrency); + } + + [Fact] + public async Task ParallelForeachExecutesConcurrentlyAndHonorsLimitAsync() + { + // Arrange + ControlledAgentProvider provider = new(barrierParticipants: 2, barrierTimeout: s_barrierTimeout); + string yaml = CreateWorkflowYaml( + items: "=[\"a\", \"b\", \"c\", \"d\", \"e\", \"f\"]", + executionOptions: """ + mode: Parallel + maxParallelism: 2 + timeoutInMilliseconds: 5000 + """, + bodyActions: InvokeAgentAction); + + // Act + WorkflowObservation observation = await RunWorkflowAsync(yaml, provider); + + // Assert + AssertNoWorkflowError(observation); + Assert.Equal(6, provider.InvocationCount); + Assert.Equal(2, provider.PeakConcurrency); + } + + [Fact] + public async Task ParallelForeachUsesBoundedDefaultLimitAsync() + { + // Arrange + ControlledAgentProvider provider = new(barrierParticipants: 4, barrierTimeout: s_barrierTimeout); + string yaml = CreateWorkflowYaml( + items: "=[\"a\", \"b\", \"c\", \"d\", \"e\", \"f\"]", + executionOptions: """ + mode: Parallel + """, + bodyActions: InvokeAgentAction); + + // Act + WorkflowObservation observation = await RunWorkflowAsync(yaml, provider); + + // Assert + AssertNoWorkflowError(observation); + Assert.Equal(6, provider.InvocationCount); + Assert.Equal(4, provider.PeakConcurrency); + } + + [Fact] + public async Task ParallelForeachCapsWorkerCountAtItemCountForExtremeLimitAsync() + { + // Arrange + ControlledAgentProvider provider = new(barrierParticipants: 2, barrierTimeout: s_barrierTimeout); + string yaml = CreateWorkflowYaml( + items: "=[\"a\", \"b\"]", + executionOptions: """ + mode: Parallel + maxParallelism: 2147483647 + """, + bodyActions: InvokeAgentAction); + + // Act + WorkflowObservation observation = await RunWorkflowAsync(yaml, provider); + + // Assert + AssertNoWorkflowError(observation); + Assert.Equal(2, provider.InvocationCount); + Assert.Equal(2, provider.PeakConcurrency); + } + + [Fact] + public async Task ParallelForeachKeepsDuplicateValuesSeparatedByIndexAsync() + { + // Arrange + ControlledAgentProvider provider = new(barrierParticipants: 3, barrierTimeout: s_barrierTimeout); + string yaml = CreateWorkflowYaml( + items: "=[\"same\", \"same\", \"same\"]", + executionOptions: """ + mode: Parallel + maxParallelism: 3 + """, + bodyActions: InvokeAgentAction); + + // Act + WorkflowObservation observation = await RunWorkflowAsync(yaml, provider); + + // Assert + AssertNoWorkflowError(observation); + Assert.Equal(["0:same", "1:same", "2:same"], GetAgentResponses(observation)); + Assert.Equal(3, provider.PeakConcurrency); + } + + [Fact] + public async Task ParallelForeachIsolatesStateAndCommitsInSourceOrderAsync() + { + // Arrange + ControlledAgentProvider provider = new( + barrierParticipants: 4, + barrierTimeout: s_barrierTimeout, + delayByIndex: index => TimeSpan.FromMilliseconds((3 - index) * 100)); + string yaml = CreateWorkflowYaml( + items: "=[\"a\", \"b\", \"c\", \"d\"]", + executionOptions: """ + mode: Parallel + maxParallelism: 4 + """, + bodyActions: $$""" + - kind: SetVariable + id: capture_last + variable: Local.LastValue + value: =Local.Item + + {{InvokeAgentAction}} + """, + afterActions: """ + - kind: SendActivity + id: report_final + activity: Final {Local.LastValue} + """); + + // Act + WorkflowObservation observation = await RunWorkflowAsync(yaml, provider); + + // Assert + AssertNoWorkflowError(observation); + Assert.Equal(s_orderedResponses, GetAgentResponses(observation)); + Assert.Contains(observation.Events.OfType(), evt => evt.Message.Trim() == "Final d"); + Assert.Equal([3, 2, 1, 0], provider.Completions); + Assert.Equal( + s_orderedResponses, + provider.Invocations.OrderBy(invocation => invocation.Index).Select(invocation => $"{invocation.Index}:{invocation.Value}")); + } + + [Fact] + public async Task ParallelForeachDeepCopiesComplexLoopValuesAsync() + { + // Arrange + ControlledAgentProvider provider = new(barrierParticipants: 2, barrierTimeout: s_barrierTimeout); + string yaml = CreateWorkflowYaml( + items: "=[Local.Shared, Local.Shared]", + executionOptions: """ + mode: Parallel + maxParallelism: 2 + """, + bodyActions: """ + - kind: EditTable + id: mutate_item + itemsVariable: Local.Item + changeType: Add + value: ={ id: Local.Index } + + - kind: InvokeAzureAgent + id: invoke_agent + agent: + name: TestAgent + input: + arguments: + value: =Text(CountRows(Local.Item)) + index: =Local.Index + output: + autoSend: true + """, + afterActions: """ + - kind: SendActivity + id: report_shared_count + activity: Shared {CountRows(Local.Shared)} + """, + beforeActions: """ + - kind: SetVariable + id: initialize_shared + variable: Local.Shared + value: =[{ id: -1 }] + """); + + // Act + WorkflowObservation observation = await RunWorkflowAsync(yaml, provider); + + // Assert + AssertNoWorkflowError(observation); + Assert.Equal(["0:2", "1:2"], GetAgentResponses(observation)); + Assert.Contains(observation.Events.OfType(), evt => evt.Message.Trim() == "Shared 1"); + } + + [Fact] + public async Task ParallelForeachAggregatesBranchFailureAsync() + { + // Arrange + ControlledAgentProvider provider = new( + barrierParticipants: 4, + barrierTimeout: s_barrierTimeout, + failureIndexes: [1]); + string yaml = CreateWorkflowYaml( + items: "=[\"a\", \"b\", \"c\", \"d\"]", + executionOptions: """ + mode: Parallel + maxParallelism: 4 + """, + bodyActions: $$""" + - kind: SendActivity + id: buffered_before_failure + activity: Branch {Local.Index} + + {{InvokeAgentAction}} + """); + + // Act + WorkflowObservation observation = await RunWorkflowAsync(yaml, provider); + + // Assert + Exception error = AssertWorkflowError(observation); + Assert.Contains(Flatten(error), exception => exception is AggregateException); + Assert.Contains(Flatten(error), exception => exception.Message.Contains("iteration 1", StringComparison.OrdinalIgnoreCase)); + Assert.Empty(GetAgentResponses(observation)); + Assert.DoesNotContain(observation.Events.OfType(), evt => evt.Message.Trim().StartsWith("Branch ", StringComparison.Ordinal)); + } + + [Fact] + public async Task ParallelForeachFailureCancelsActivePeersAsync() + { + // Arrange + ControlledAgentProvider provider = new( + barrierParticipants: 4, + barrierTimeout: s_barrierTimeout, + failureIndexes: [0], + waitUntilCanceledIndexes: [1, 2, 3]); + string yaml = CreateWorkflowYaml( + items: "=[\"a\", \"b\", \"c\", \"d\"]", + executionOptions: """ + mode: Parallel + maxParallelism: 4 + """, + bodyActions: InvokeAgentAction); + + // Act + WorkflowObservation observation = await RunWorkflowAsync(yaml, provider); + + // Assert + Exception error = AssertWorkflowError(observation); + Assert.Contains(Flatten(error), exception => exception.Message.Contains("iteration 0", StringComparison.OrdinalIgnoreCase)); + await AwaitWithTimeoutAsync(provider.CancellationObserved, TimeSpan.FromSeconds(5)); + await AwaitWithTimeoutAsync(provider.NoActiveInvocations, TimeSpan.FromSeconds(5)); + Assert.Equal(0, provider.ActiveCount); + Assert.Empty(GetAgentResponses(observation)); + } + + [Fact] + public async Task ParallelForeachPropagatesCancellationAsync() + { + // Arrange + ControlledAgentProvider provider = new(waitUntilCanceled: true); + string yaml = CreateWorkflowYaml( + items: "=[\"a\", \"b\"]", + executionOptions: """ + mode: Parallel + maxParallelism: 2 + """, + bodyActions: InvokeAgentAction); + Workflow workflow = BuildWorkflow(yaml, provider); + await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, "input"); + Task watchTask = CollectEventsAsync(run); + await AwaitWithTimeoutAsync(provider.FirstInvocationStarted, TimeSpan.FromSeconds(5)); + + // Act + await run.CancelRunAsync(); + WorkflowEvent[] events = await AwaitWithTimeoutAsync(watchTask, TimeSpan.FromSeconds(5)); + RunStatus status = await run.GetStatusAsync(); + + // Assert + Assert.Equal(RunStatus.Ended, status); + await AwaitWithTimeoutAsync(provider.CancellationObserved, TimeSpan.FromSeconds(5)); + await AwaitWithTimeoutAsync(provider.NoActiveInvocations, TimeSpan.FromSeconds(5)); + Assert.Equal(0, provider.ActiveCount); + Assert.DoesNotContain(events.OfType(), evt => evt.ActionId == "parallel_loop"); + Assert.DoesNotContain(events.OfType(), evt => evt.ActionId == "invoke_agent"); + } + + [Fact] + public async Task ParallelForeachTimesOutIterationAndCancelsPeersAsync() + { + // Arrange + ControlledAgentProvider provider = new(waitUntilCanceled: true); + string yaml = CreateWorkflowYaml( + items: "=[\"a\", \"b\"]", + executionOptions: """ + mode: Parallel + maxParallelism: 2 + timeoutInMilliseconds: 1000 + """, + bodyActions: InvokeAgentAction); + + // Act + WorkflowObservation observation = await RunWorkflowAsync(yaml, provider); + + // Assert + Exception error = AssertWorkflowError(observation); + Assert.Contains(Flatten(error), exception => exception is TimeoutException); + await AwaitWithTimeoutAsync(provider.CancellationObserved, TimeSpan.FromSeconds(5)); + await AwaitWithTimeoutAsync(provider.NoActiveInvocations, TimeSpan.FromSeconds(5)); + Assert.Equal(0, provider.ActiveCount); + Assert.Empty(GetAgentResponses(observation)); + Assert.DoesNotContain(observation.Events.OfType(), evt => evt.ActionId == "invoke_agent"); + } + + [Fact] + public async Task ParallelForeachHandlesEmptyCollectionAsync() + { + // Arrange + ControlledAgentProvider provider = new(); + string yaml = CreateWorkflowYaml( + items: "=[]", + executionOptions: """ + mode: Parallel + maxParallelism: 3 + """, + bodyActions: InvokeAgentAction, + afterActions: """ + - kind: SendActivity + id: empty_complete + activity: empty complete + """); + + // Act + WorkflowObservation observation = await RunWorkflowAsync(yaml, provider); + + // Assert + AssertNoWorkflowError(observation); + Assert.Equal(0, provider.InvocationCount); + Assert.Contains(observation.Events.OfType(), evt => evt.ActionId == "parallel_loop"); + Assert.Contains(observation.Events.OfType(), evt => evt.Message.Trim() == "empty complete"); + } + + [Fact] + public async Task ParallelForeachCheckpointResumesAfterCommittedIterationsAsync() + { + // Arrange + ControlledAgentProvider provider = new(barrierParticipants: 2, barrierTimeout: s_barrierTimeout); + string yaml = CreateWorkflowYaml( + items: "=[\"a\", \"b\"]", + executionOptions: """ + mode: Parallel + maxParallelism: 2 + """, + bodyActions: $$""" + - kind: SetVariable + id: capture_checkpoint_value + variable: Local.LastValue + value: =Local.Item + + {{InvokeAgentAction}} + """, + afterActions: """ + - kind: SendActivity + id: after_parallel + activity: after {Local.LastValue} + """); + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + CheckpointInfo? checkpointAfterLoop = null; + bool loopCompleted = false; + + // Act + await using (StreamingRun firstRun = await InProcessExecution.RunStreamingAsync( + BuildWorkflow(yaml, provider), + "input", + checkpointManager)) + { + await foreach (WorkflowEvent workflowEvent in firstRun.WatchStreamAsync()) + { + if (workflowEvent is DeclarativeActionCompletedEvent { ActionId: "parallel_loop" }) + { + loopCompleted = true; + } + + if (loopCompleted && + checkpointAfterLoop is null && + workflowEvent is SuperStepCompletedEvent { CompletionInfo.Checkpoint: { } checkpoint }) + { + checkpointAfterLoop = checkpoint; + } + } + } + + int invocationCountBeforeResume = provider.InvocationCount; + await using StreamingRun resumedRun = await InProcessExecution.ResumeStreamingAsync( + BuildWorkflow(yaml, provider), + Assert.IsType(checkpointAfterLoop), + checkpointManager); + WorkflowEvent[] resumedEvents = await CollectEventsAsync(resumedRun); + + // Assert + Assert.Equal(2, invocationCountBeforeResume); + Assert.Equal(invocationCountBeforeResume, provider.InvocationCount); + Assert.DoesNotContain(resumedEvents.OfType(), evt => evt.ActionId == "parallel_loop"); + Assert.DoesNotContain(resumedEvents, workflowEvent => workflowEvent is AgentResponseEvent { ExecutorId: "invoke_agent" }); + Assert.Contains(resumedEvents.OfType(), evt => evt.Message.Trim() == "after b"); + } + + [Theory] + [InlineData("Parallel", 0)] + [InlineData("Parallel", -1)] + [InlineData("unsupported", 2)] + [InlineData("\"1\"", 2)] + [InlineData("Sequential, Parallel", 2)] + [InlineData("null", 2)] + [InlineData("true", 2)] + public void ParallelForeachRejectsInvalidConfiguration(string mode, int maxParallelism) + { + // Arrange + string yaml = CreateWorkflowYaml( + items: "=[\"a\"]", + executionOptions: $$""" + mode: {{mode}} + maxParallelism: {{maxParallelism}} + """, + bodyActions: InvokeAgentAction); + + // Act + DeclarativeModelException exception = Assert.Throws(() => BuildWorkflow(yaml, new ControlledAgentProvider())); + + // Assert + Assert.Contains("parallel", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void ParallelForeachRejectsInvalidTimeout(int timeoutInMilliseconds) + { + // Arrange + string yaml = CreateWorkflowYaml( + items: "=[\"a\"]", + executionOptions: $$""" + mode: Parallel + maxParallelism: 2 + timeoutInMilliseconds: {{timeoutInMilliseconds}} + """, + bodyActions: InvokeAgentAction); + + // Act + DeclarativeModelException exception = Assert.Throws(() => BuildWorkflow(yaml, new ControlledAgentProvider())); + + // Assert + Assert.Contains("timeout", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("1.5")] + [InlineData("2147483648")] + [InlineData("\"2\"")] + [InlineData("-2147483649")] + [InlineData("null")] + [InlineData("true")] + public void ParallelForeachRejectsNonIntegerOrOutOfRangeMaxParallelism(string maxParallelism) + { + // Arrange + string yaml = CreateWorkflowYaml( + items: "=[\"a\"]", + executionOptions: $$""" + mode: Parallel + maxParallelism: {{maxParallelism}} + """, + bodyActions: InvokeAgentAction); + + // Act + DeclarativeModelException exception = Assert.Throws(() => BuildWorkflow(yaml, new ControlledAgentProvider())); + + // Assert + Assert.Contains("maxParallelism", exception.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("1.5")] + [InlineData("2147483648")] + [InlineData("\"1000\"")] + [InlineData("-2147483649")] + [InlineData("null")] + [InlineData("true")] + public void ParallelForeachRejectsNonIntegerOrOutOfRangeTimeout(string timeoutInMilliseconds) + { + // Arrange + string yaml = CreateWorkflowYaml( + items: "=[\"a\"]", + executionOptions: $$""" + mode: Parallel + timeoutInMilliseconds: {{timeoutInMilliseconds}} + """, + bodyActions: InvokeAgentAction); + + // Act + DeclarativeModelException exception = Assert.Throws(() => BuildWorkflow(yaml, new ControlledAgentProvider())); + + // Assert + Assert.Contains("timeout", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ParallelForeachRejectsCheckpointingBody() + { + // Arrange + string yaml = CreateWorkflowYaml( + items: "=[\"a\"]", + executionOptions: """ + mode: Parallel + maxParallelism: 2 + """, + bodyActions: """ + - kind: Question + id: prompt_in_loop + alwaysPrompt: true + autoSend: false + property: Local.Answer + prompt: + kind: Message + text: + - answer + entity: + kind: StringPrebuiltEntity + """); + + // Act + DeclarativeModelException exception = Assert.Throws(() => BuildWorkflow(yaml, new ControlledAgentProvider())); + + // Assert + Assert.Contains("checkpoint", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("BreakLoop")] + [InlineData("ContinueLoop")] + public void ParallelForeachRejectsOuterLoopControl(string actionKind) + { + // Arrange + string yaml = CreateWorkflowYaml( + items: "=[\"a\"]", + executionOptions: """ + mode: Parallel + maxParallelism: 2 + """, + bodyActions: $$""" + - kind: {{actionKind}} + id: unsupported_loop_control + """); + + // Act + DeclarativeModelException exception = Assert.Throws(() => BuildWorkflow(yaml, new ControlledAgentProvider())); + + // Assert + Assert.Contains(actionKind, exception.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("BreakLoop")] + [InlineData("ContinueLoop")] + public async Task ParallelForeachAllowsNestedSequentialLoopControlAsync(string actionKind) + { + // Arrange + ControlledAgentProvider provider = new(barrierParticipants: 2, barrierTimeout: s_barrierTimeout); + string yaml = CreateWorkflowYaml( + items: "=[\"a\", \"b\"]", + executionOptions: """ + mode: Parallel + maxParallelism: 2 + """, + bodyActions: $$""" + - kind: Foreach + id: inner_loop + items: =[1, 2] + value: Local.InnerItem + index: Local.InnerIndex + actions: + - kind: {{actionKind}} + id: control_inner_loop + + {{InvokeAgentAction}} + """); + + // Act + WorkflowObservation observation = await RunWorkflowAsync(yaml, provider); + + // Assert + AssertNoWorkflowError(observation); + Assert.Equal(["0:a", "1:b"], GetAgentResponses(observation)); + Assert.Equal(2, provider.PeakConcurrency); + } + + [Fact] + public async Task ParallelForeachRejectsConditionalRequestAtRuntimeAsync() + { + // Arrange + ControlledAgentProvider provider = new(requestApproval: true); + string yaml = CreateWorkflowYaml( + items: "=[\"a\"]", + executionOptions: """ + mode: Parallel + maxParallelism: 2 + """, + bodyActions: InvokeAgentAction); + + // Act + WorkflowObservation observation = await RunWorkflowAsync(yaml, provider); + + // Assert + Exception error = AssertWorkflowError(observation); + Assert.Contains(Flatten(error), exception => exception.Message.Contains("checkpoint", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(observation.Events, workflowEvent => workflowEvent is RequestInfoEvent); + } + + private static readonly string[] s_orderedResponses = ["0:a", "1:b", "2:c", "3:d"]; + + private static readonly TimeSpan s_barrierTimeout = TimeSpan.FromSeconds(5); + + private const string InvokeAgentAction = """ + - kind: InvokeAzureAgent + id: invoke_agent + agent: + name: TestAgent + input: + arguments: + value: =Local.Item + index: =Local.Index + output: + autoSend: true + """; + + private static string CreateWorkflowYaml( + string items, + string? executionOptions, + string bodyActions, + string? afterActions = null, + string? beforeActions = null) => + $$""" + kind: Workflow + trigger: + kind: OnConversationStart + id: workflow + actions: + {{beforeActions}} + - kind: Foreach + id: parallel_loop + items: {{items}} + value: Local.Item + index: Local.Index + {{executionOptions}} + actions: + {{bodyActions}} + {{afterActions}} + """; + + private static Workflow BuildWorkflow(string yaml, ResponseAgentProvider provider) + { + using StringReader reader = new(yaml); + return DeclarativeWorkflowBuilder.Build(reader, new DeclarativeWorkflowOptions(provider)); + } + + private static async Task RunWorkflowAsync(string yaml, ResponseAgentProvider provider) + { + using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10)); + Workflow workflow = BuildWorkflow(yaml, provider); + await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, "input", cancellationToken: timeout.Token).ConfigureAwait(false); + WorkflowEvent[] events = await CollectEventsAsync(run, timeout.Token).ConfigureAwait(false); + RunStatus status = await run.GetStatusAsync(timeout.Token).ConfigureAwait(false); + return new(events, status); + } + + private static async Task CollectEventsAsync(StreamingRun run, CancellationToken cancellationToken = default) + { + List events = []; + await foreach (WorkflowEvent workflowEvent in run.WatchStreamAsync(cancellationToken).ConfigureAwait(false)) + { + events.Add(workflowEvent); + } + + return [.. events]; + } + + private static void AssertNoWorkflowError(WorkflowObservation observation) + { + Assert.DoesNotContain(observation.Events, workflowEvent => workflowEvent is WorkflowErrorEvent); + Assert.Equal(RunStatus.Idle, observation.Status); + } + + private static Exception AssertWorkflowError(WorkflowObservation observation) => + Assert.IsAssignableFrom(Assert.Single(observation.Events.OfType()).Data); + + private static string[] GetAgentResponses(WorkflowObservation observation) => + [.. observation.Events + .OfType() + .Where(evt => evt.ExecutorId == "invoke_agent") + .Select(evt => evt.Response.Messages.Single().Text)]; + + private static IEnumerable Flatten(Exception exception) + { + yield return exception; + + if (exception is AggregateException aggregateException) + { + foreach (Exception innerException in aggregateException.InnerExceptions.SelectMany(Flatten)) + { + yield return innerException; + } + } + else if (exception.InnerException is not null) + { + foreach (Exception innerException in Flatten(exception.InnerException)) + { + yield return innerException; + } + } + } + + private static async Task AwaitWithTimeoutAsync(Task task, TimeSpan timeout) + { + Task completedTask = await Task.WhenAny(task, Task.Delay(timeout)).ConfigureAwait(false); + Assert.Same(task, completedTask); + await task.ConfigureAwait(false); + } + + private static async Task AwaitWithTimeoutAsync(Task task, TimeSpan timeout) + { + await AwaitWithTimeoutAsync((Task)task, timeout).ConfigureAwait(false); + return await task.ConfigureAwait(false); + } + + private sealed record WorkflowObservation(WorkflowEvent[] Events, RunStatus Status); + + private sealed record Invocation(int Index, string Value); + + private sealed class ControlledAgentProvider : ResponseAgentProvider + { + private readonly TaskCompletionSource _barrier = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _cancellationObserved = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _firstInvocationStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _noActiveInvocations = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly int _barrierParticipants; + private readonly TimeSpan _barrierTimeout; + private readonly Func? _delayByIndex; + private readonly HashSet _failureIndexes; + private readonly HashSet _waitUntilCanceledIndexes; + private readonly bool _waitUntilCanceled; + private readonly bool _requestApproval; + private int _activeCount; + private int _barrierArrivals; + private int _invocationCount; + private int _peakConcurrency; + + public ControlledAgentProvider( + int barrierParticipants = 1, + TimeSpan? barrierTimeout = null, + Func? delayByIndex = null, + IEnumerable? failureIndexes = null, + IEnumerable? waitUntilCanceledIndexes = null, + bool waitUntilCanceled = false, + bool requestApproval = false) + { + this._barrierParticipants = barrierParticipants; + this._barrierTimeout = barrierTimeout ?? TimeSpan.Zero; + this._delayByIndex = delayByIndex; + this._failureIndexes = failureIndexes is null ? [] : [.. failureIndexes]; + this._waitUntilCanceledIndexes = waitUntilCanceledIndexes is null ? [] : [.. waitUntilCanceledIndexes]; + this._waitUntilCanceled = waitUntilCanceled; + this._requestApproval = requestApproval; + } + + public int InvocationCount => Volatile.Read(ref this._invocationCount); + + public int PeakConcurrency => Volatile.Read(ref this._peakConcurrency); + + public int ActiveCount => Volatile.Read(ref this._activeCount); + + public ConcurrentBag Invocations { get; } = []; + + public ConcurrentQueue Completions { get; } = []; + + public Task CancellationObserved => this._cancellationObserved.Task; + + public Task FirstInvocationStarted => this._firstInvocationStarted.Task; + + public Task NoActiveInvocations => this._noActiveInvocations.Task; + + public override Task CreateConversationAsync(CancellationToken cancellationToken = default) => + Task.FromResult(Guid.NewGuid().ToString("N")); + + public override Task CreateMessageAsync(string conversationId, ChatMessage conversationMessage, CancellationToken cancellationToken = default) => + Task.FromResult(conversationMessage); + + public override Task GetMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public override async IAsyncEnumerable InvokeAgentAsync( + string agentId, + string? agentVersion, + string? conversationId, + IEnumerable? messages, + IDictionary? inputArguments, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + int index = Convert.ToInt32(inputArguments!["index"], CultureInfo.InvariantCulture); + string value = Convert.ToString(inputArguments["value"], CultureInfo.InvariantCulture)!; + this.Invocations.Add(new(index, value)); + Interlocked.Increment(ref this._invocationCount); + this._firstInvocationStarted.TrySetResult(true); + + int activeCount = Interlocked.Increment(ref this._activeCount); + UpdatePeak(ref this._peakConcurrency, activeCount); + using CancellationTokenRegistration registration = cancellationToken.Register(() => this._cancellationObserved.TrySetResult(true)); + + try + { + if (Interlocked.Increment(ref this._barrierArrivals) == this._barrierParticipants) + { + this._barrier.TrySetResult(true); + } + + if (this._barrierParticipants > 1) + { + await Task.WhenAny(this._barrier.Task, Task.Delay(this._barrierTimeout, cancellationToken)).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + } + + if (this._waitUntilCanceled || this._waitUntilCanceledIndexes.Contains(index)) + { + await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); + } + + if (this._delayByIndex is not null) + { + await Task.Delay(this._delayByIndex(index), cancellationToken).ConfigureAwait(false); + } + + if (this._failureIndexes.Contains(index)) + { + throw new InvalidOperationException($"Failure for iteration {index}."); + } + + this.Completions.Enqueue(index); + + if (this._requestApproval) + { + yield return new AgentResponseUpdate( + ChatRole.Assistant, + [new ToolApprovalRequestContent("approval", new FunctionCallContent("approval", "test"))]); + } + else + { + yield return new AgentResponseUpdate(ChatRole.Assistant, $"{index}:{value}"); + } + } + finally + { + if (cancellationToken.IsCancellationRequested) + { + this._cancellationObserved.TrySetResult(true); + } + + if (Interlocked.Decrement(ref this._activeCount) == 0) + { + this._noActiveInvocations.TrySetResult(true); + } + } + } + + public override async IAsyncEnumerable GetMessagesAsync( + string conversationId, + int? limit = null, + string? after = null, + string? before = null, + bool newestFirst = false, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.CompletedTask.ConfigureAwait(false); + yield break; + } + + private static void UpdatePeak(ref int peak, int candidate) + { + int observed; + do + { + observed = Volatile.Read(ref peak); + if (candidate <= observed) + { + return; + } + } + while (Interlocked.CompareExchange(ref peak, candidate, observed) != observed); + } + } +}